loki-mode 11.75.5 → 11.76.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.
Files changed (79) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/api/openapi.yaml +8 -0
  4. package/api/routes/memory.ts +33 -30
  5. package/api/types/memory.ts +5 -4
  6. package/autonomy/checklist-verify.py +55 -1
  7. package/autonomy/edit.sh +17 -0
  8. package/autonomy/loki +86 -16
  9. package/autonomy/prd-checklist.sh +38 -2
  10. package/autonomy/receipt_jwt.py +43 -6
  11. package/autonomy/run.sh +35 -4
  12. package/autonomy/verify.sh +19 -0
  13. package/dashboard/__init__.py +1 -1
  14. package/dashboard/server.py +42 -26
  15. package/docs/CLI-REFERENCE.md +3 -2
  16. package/docs/INSTALLATION.md +1 -1
  17. package/loki-ts/dist/loki.js +1331 -1321
  18. package/mcp/__init__.py +1 -1
  19. package/package.json +2 -2
  20. package/packages/control-plane/dist/server.js +69505 -8712
  21. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  22. package/providers/claude.sh +23 -0
  23. package/web-app/dist/assets/{AdminPage-BS4ELBAw.js → AdminPage-DoGPqZIa.js} +1 -1
  24. package/web-app/dist/assets/{Avatar-Ctxnyl6v.js → Avatar-Dyf7xJsz.js} +1 -1
  25. package/web-app/dist/assets/{Badge-Dha5mH_y.js → Badge-Cm4dADew.js} +1 -1
  26. package/web-app/dist/assets/{Button-DEFwyJ-y.js → Button-LGxjldY5.js} +1 -1
  27. package/web-app/dist/assets/{CockpitPage-FI6pipM5.js → CockpitPage-DuC9g48J.js} +1 -1
  28. package/web-app/dist/assets/{ComparePage-BZyqPUyz.js → ComparePage-DuTrmedK.js} +1 -1
  29. package/web-app/dist/assets/{ErrorBoundary-C42qX-TC.js → ErrorBoundary-B0f7NEiT.js} +1 -1
  30. package/web-app/dist/assets/{EvidenceReceiptPanel-BP5b8y-p.js → EvidenceReceiptPanel-DsE8B9Hw.js} +1 -1
  31. package/web-app/dist/assets/{GitHubIssuesPanel-C1YegBaB.js → GitHubIssuesPanel-aYflOqd4.js} +1 -1
  32. package/web-app/dist/assets/{GitHubPRsPanel-BfHTJ9Eh.js → GitHubPRsPanel-CCJGck6c.js} +1 -1
  33. package/web-app/dist/assets/{HomePage-BLojEQpg.js → HomePage-BJU3PVkJ.js} +2 -2
  34. package/web-app/dist/assets/{LoginPage-BERK_nOr.js → LoginPage-UzYoF5CJ.js} +1 -1
  35. package/web-app/dist/assets/{MagicPage-BFyESmS9.js → MagicPage-5X3hQ7lt.js} +1 -1
  36. package/web-app/dist/assets/{MetricsPage-BkiDwXjv.js → MetricsPage-CFsTNyOu.js} +1 -1
  37. package/web-app/dist/assets/{NotFoundPage-C9OhYQwv.js → NotFoundPage-Ba0nMlqG.js} +1 -1
  38. package/web-app/dist/assets/{ProjectPage-Ca6GOQ10.js → ProjectPage-fvU-EYZb.js} +3 -3
  39. package/web-app/dist/assets/{ProjectsPage-jR9YsQcT.js → ProjectsPage-CVEKCorG.js} +1 -1
  40. package/web-app/dist/assets/{SettingsPage-RMscGLpx.js → SettingsPage-JGbsNmqD.js} +1 -1
  41. package/web-app/dist/assets/{ShowcasePage-DltjwJOU.js → ShowcasePage-Dr2c_V-y.js} +1 -1
  42. package/web-app/dist/assets/{SystemSettingsPage-Ciw9D_UW.js → SystemSettingsPage-D9dCc4Ir.js} +1 -1
  43. package/web-app/dist/assets/{TeamsPage-pzZwems7.js → TeamsPage-B56u-bod.js} +1 -1
  44. package/web-app/dist/assets/{TemplatesPage-B-1af8L3.js → TemplatesPage-CUwqj6ge.js} +1 -1
  45. package/web-app/dist/assets/{TerminalOutput-DMN3z0HK.js → TerminalOutput-Cm920SDi.js} +1 -1
  46. package/web-app/dist/assets/{activity-C08J4j8q.js → activity-DDF6juD-.js} +1 -1
  47. package/web-app/dist/assets/{bell-DYLraLib.js → bell-7MW_oAcb.js} +1 -1
  48. package/web-app/dist/assets/{bot-BxX6Ju7d.js → bot-b63uz2Ov.js} +1 -1
  49. package/web-app/dist/assets/{check-DLWIvQFb.js → check-2oBMAALa.js} +1 -1
  50. package/web-app/dist/assets/{chevron-left-DjlGKKMc.js → chevron-left-UuhI6pbU.js} +1 -1
  51. package/web-app/dist/assets/{circle-alert-sGquPyzb.js → circle-alert-BikRzQA7.js} +1 -1
  52. package/web-app/dist/assets/{clock-Csd85SYx.js → clock-BdkTaAgO.js} +1 -1
  53. package/web-app/dist/assets/{cloud-OWFEPptY.js → cloud-oKg24kTO.js} +1 -1
  54. package/web-app/dist/assets/{code-xml-DE_Q-qTt.js → code-xml-CpDmdnYQ.js} +1 -1
  55. package/web-app/dist/assets/{database-BwKw-RDm.js → database-BT133u2w.js} +1 -1
  56. package/web-app/dist/assets/{dollar-sign-Dc094NJM.js → dollar-sign-khUEgmXm.js} +1 -1
  57. package/web-app/dist/assets/{file-code-corner-DZ55YOQD.js → file-code-corner-_TlPqN5g.js} +1 -1
  58. package/web-app/dist/assets/{file-plus-CAQ8lIHM.js → file-plus-B5UT0SZU.js} +1 -1
  59. package/web-app/dist/assets/{globe-CaDarghB.js → globe-Chhrgg2K.js} +1 -1
  60. package/web-app/dist/assets/{hammer-CisWLZ8D.js → hammer-DJ6Z4tiA.js} +1 -1
  61. package/web-app/dist/assets/{index-f_tFv9kF.js → index-BprNVRFx.js} +3 -3
  62. package/web-app/dist/assets/{layers-Z1x4KEEu.js → layers-CtYWuenz.js} +1 -1
  63. package/web-app/dist/assets/{loader-circle-D8nqRKom.js → loader-circle-BRxnh_Va.js} +1 -1
  64. package/web-app/dist/assets/{lock-CABix5Cb.js → lock-btaZm2bN.js} +1 -1
  65. package/web-app/dist/assets/{package-BGgemZxG.js → package-BFU4d6gL.js} +1 -1
  66. package/web-app/dist/assets/{plus-DxXIlocN.js → plus-BogXrZQi.js} +1 -1
  67. package/web-app/dist/assets/{refresh-cw-B8o443rD.js → refresh-cw-CsdQkR5n.js} +1 -1
  68. package/web-app/dist/assets/{rotate-ccw-CFEIXGbz.js → rotate-ccw-BeLxChlE.js} +1 -1
  69. package/web-app/dist/assets/{scroll-text-BsAHSnH0.js → scroll-text-DAXSJc0i.js} +1 -1
  70. package/web-app/dist/assets/{server-P1jAUtuA.js → server-Cf_yJUqU.js} +1 -1
  71. package/web-app/dist/assets/{shield-alert-BV1NlBTX.js → shield-alert-B2gh1hM3.js} +1 -1
  72. package/web-app/dist/assets/{thumbs-up-B_kq8bxY.js → thumbs-up-DxYjJ1v8.js} +1 -1
  73. package/web-app/dist/assets/{trash-2-CEattzGU.js → trash-2-BaAsSOYk.js} +1 -1
  74. package/web-app/dist/assets/{trending-down-C0RAOlcS.js → trending-down-Kc-Tady7.js} +1 -1
  75. package/web-app/dist/assets/{trending-up-BvpC5vRI.js → trending-up-BDsns895.js} +1 -1
  76. package/web-app/dist/assets/{upload-Pza4kWFV.js → upload-CCJzLwO9.js} +1 -1
  77. package/web-app/dist/assets/{usePolling-ZKIvgKQy.js → usePolling-4aqc_A7F.js} +1 -1
  78. package/web-app/dist/assets/{user-BBKj_bUy.js → user-BzoHXLDe.js} +1 -1
  79. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Runs an autonomous software factory that knows what it is supposed to deliver and proves it did, with a signed receipt. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.75.5
6
+ # Loki Mode v11.76.0
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
9
9
 
@@ -475,4 +475,4 @@ Implemented, planned and deprecated items, with the version each landed in: `ski
475
475
 
476
476
  ---
477
477
 
478
- **v11.75.5 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
478
+ **v11.76.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.75.5
1
+ 11.76.0
package/api/openapi.yaml CHANGED
@@ -1166,12 +1166,16 @@ components:
1166
1166
  properties:
1167
1167
  discoveryTokens:
1168
1168
  type: integer
1169
+ nullable: true
1169
1170
  readTokens:
1170
1171
  type: integer
1172
+ nullable: true
1171
1173
  ratio:
1172
1174
  type: number
1175
+ nullable: true
1173
1176
  savingsPercent:
1174
1177
  type: number
1178
+ nullable: true
1175
1179
 
1176
1180
  RetrieveResponse:
1177
1181
  type: object
@@ -1194,12 +1198,16 @@ components:
1194
1198
  properties:
1195
1199
  discoveryTokens:
1196
1200
  type: integer
1201
+ nullable: true
1197
1202
  readTokens:
1198
1203
  type: integer
1204
+ nullable: true
1199
1205
  ratio:
1200
1206
  type: number
1207
+ nullable: true
1201
1208
  savingsPercent:
1202
1209
  type: number
1210
+ nullable: true
1203
1211
 
1204
1212
  security:
1205
1213
  - {}
@@ -41,6 +41,24 @@ import {
41
41
  // Base path for memory storage
42
42
  const MEMORY_BASE_PATH = ".loki/memory";
43
43
 
44
+ // Python helper shared by the token-economics scripts. A session that recorded
45
+ // nothing (all counters zero) is unmeasured: every metric is null, never 0 or
46
+ // a 100% saving. Once anything is recorded the measured values pass through.
47
+ const PY_TOKEN_METRICS = `
48
+ def token_metrics_from(summary):
49
+ m = summary.get('metrics', {})
50
+ keys = ('discovery_tokens', 'read_tokens', 'layer1_loads', 'layer2_loads',
51
+ 'layer3_loads', 'cache_hits', 'cache_misses')
52
+ if not any((m.get(k) or 0) > 0 for k in keys):
53
+ return {'discoveryTokens': None, 'readTokens': None, 'ratio': None, 'savingsPercent': None}
54
+ return {
55
+ 'discoveryTokens': m.get('discovery_tokens'),
56
+ 'readTokens': m.get('read_tokens'),
57
+ 'ratio': summary.get('ratio'),
58
+ 'savingsPercent': summary.get('savings_percent')
59
+ }
60
+ `;
61
+
44
62
  // Default subprocess timeout (30 seconds)
45
63
  const SUBPROCESS_TIMEOUT_MS = 30000;
46
64
 
@@ -181,6 +199,7 @@ import json
181
199
  sys.path.insert(0, '.')
182
200
  from memory.engine import MemoryEngine
183
201
  from memory.token_economics import TokenEconomics
202
+ ${PY_TOKEN_METRICS}
184
203
 
185
204
  engine = MemoryEngine('${MEMORY_BASE_PATH}')
186
205
  stats = engine.get_stats()
@@ -195,12 +214,7 @@ try:
195
214
  te = TokenEconomics('api-session', '${MEMORY_BASE_PATH}')
196
215
  te.load()
197
216
  summary = te.get_summary()
198
- token_metrics = {
199
- 'discoveryTokens': summary['metrics'].get('discovery_tokens', 0),
200
- 'readTokens': summary['metrics'].get('read_tokens', 0),
201
- 'ratio': summary.get('ratio', 0),
202
- 'savingsPercent': summary.get('savings_percent', 100)
203
- }
217
+ token_metrics = token_metrics_from(summary)
204
218
  except:
205
219
  token_metrics = None
206
220
 
@@ -746,6 +760,7 @@ from memory.engine import MemoryEngine
746
760
  from memory.retrieval import MemoryRetrieval
747
761
  from memory.storage import MemoryStorage
748
762
  from memory.token_economics import TokenEconomics
763
+ ${PY_TOKEN_METRICS}
749
764
 
750
765
  storage = MemoryStorage('${MEMORY_BASE_PATH}')
751
766
  engine = MemoryEngine(storage=storage, base_path='${MEMORY_BASE_PATH}')
@@ -775,19 +790,9 @@ try:
775
790
  te = TokenEconomics('retrieve-session', '${MEMORY_BASE_PATH}')
776
791
  te.load()
777
792
  summary = te.get_summary()
778
- token_metrics = {
779
- 'discoveryTokens': summary['metrics'].get('discovery_tokens', 0),
780
- 'readTokens': summary['metrics'].get('read_tokens', 0),
781
- 'ratio': summary.get('ratio', 0),
782
- 'savingsPercent': summary.get('savings_percent', 100)
783
- }
793
+ token_metrics = token_metrics_from(summary)
784
794
  except:
785
- token_metrics = {
786
- 'discoveryTokens': 0,
787
- 'readTokens': 0,
788
- 'ratio': 0,
789
- 'savingsPercent': 100
790
- }
795
+ token_metrics = token_metrics_from({})
791
796
 
792
797
  result = {
793
798
  'memories': memories,
@@ -840,10 +845,10 @@ print(json.dumps(result, default=str))
840
845
  const emptyResponse: RetrieveResponse = {
841
846
  memories: [],
842
847
  tokenMetrics: {
843
- discoveryTokens: 0,
844
- readTokens: 0,
845
- ratio: 0,
846
- savingsPercent: 100,
848
+ discoveryTokens: null,
849
+ readTokens: null,
850
+ ratio: null,
851
+ savingsPercent: null,
847
852
  },
848
853
  };
849
854
  return successResponse(emptyResponse);
@@ -949,6 +954,7 @@ import sys
949
954
  import json
950
955
  sys.path.insert(0, '.')
951
956
  from memory.token_economics import TokenEconomics
957
+ ${PY_TOKEN_METRICS}
952
958
 
953
959
  te = TokenEconomics('api-session', '${MEMORY_BASE_PATH}')
954
960
  te.load()
@@ -957,10 +963,7 @@ summary = te.get_summary()
957
963
  result = {
958
964
  'sessionId': summary.get('session_id', 'unknown'),
959
965
  'startedAt': summary.get('started_at', ''),
960
- 'discoveryTokens': summary['metrics'].get('discovery_tokens', 0),
961
- 'readTokens': summary['metrics'].get('read_tokens', 0),
962
- 'ratio': summary.get('ratio', 0),
963
- 'savingsPercent': summary.get('savings_percent', 100),
966
+ **token_metrics_from(summary),
964
967
  'layer1Loads': summary['metrics'].get('layer1_loads', 0),
965
968
  'layer2Loads': summary['metrics'].get('layer2_loads', 0),
966
969
  'layer3Loads': summary['metrics'].get('layer3_loads', 0),
@@ -991,10 +994,10 @@ print(json.dumps(result))
991
994
  const defaultEconomics: TokenEconomicsDetail = {
992
995
  sessionId: "none",
993
996
  startedAt: new Date().toISOString(),
994
- discoveryTokens: 0,
995
- readTokens: 0,
996
- ratio: 0,
997
- savingsPercent: 100,
997
+ discoveryTokens: null,
998
+ readTokens: null,
999
+ ratio: null,
1000
+ savingsPercent: null,
998
1001
  layer1Loads: 0,
999
1002
  layer2Loads: 0,
1000
1003
  layer3Loads: 0,
@@ -9,10 +9,11 @@
9
9
  // -----------------------------------------------------------------------------
10
10
 
11
11
  export interface TokenMetrics {
12
- discoveryTokens: number;
13
- readTokens: number;
14
- ratio: number;
15
- savingsPercent: number;
12
+ // null until the session recorded token usage; never a fabricated 0 or 100
13
+ discoveryTokens: number | null;
14
+ readTokens: number | null;
15
+ ratio: number | null;
16
+ savingsPercent: number | null;
16
17
  }
17
18
 
18
19
  export interface ThresholdAction {
@@ -16,10 +16,12 @@ Timeout = item stays 'pending' (not 'failed') to prevent false failures.
16
16
  Atomic writes: temp file + os.replace() to never produce partial JSON.
17
17
 
18
18
  Usage:
19
- python3 checklist-verify.py [--checklist PATH] [--timeout SECS]
19
+ python3 checklist-verify.py [--checklist PATH] [--timeout SECS] (--freeze | --expect-digest SHA256)
20
20
  """
21
21
 
22
22
  import argparse
23
+ import hashlib
24
+ import hmac
23
25
  import json
24
26
  import os
25
27
  import re
@@ -422,6 +424,27 @@ def determine_item_status(verifications: list) -> str:
422
424
  return "pending"
423
425
 
424
426
 
427
+ _DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
428
+
429
+ # Result fields the verifier writes back; never part of a check definition.
430
+ _RESULT_FIELDS = ("passed", "output", "status", "verified_at")
431
+
432
+
433
+ def definition_digest(checklist: dict) -> str:
434
+ """SHA-256 over check definitions only (item ids + verification specs)."""
435
+ defs = []
436
+ for category in checklist.get("categories", []):
437
+ for item in category.get("items", []):
438
+ checks = [
439
+ {k: v for k, v in c.items() if k not in _RESULT_FIELDS}
440
+ for c in item.get("verification", [])
441
+ if isinstance(c, dict)
442
+ ]
443
+ defs.append({"id": item.get("id", ""), "verification": checks})
444
+ blob = json.dumps(defs, sort_keys=True, separators=(",", ":"))
445
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
446
+
447
+
425
448
  def atomic_write_json(path: str, data: dict) -> None:
426
449
  """Write JSON atomically via temp file + os.replace()."""
427
450
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -454,6 +477,17 @@ def main():
454
477
  default=30,
455
478
  help="Timeout per check in seconds (default: 30)",
456
479
  )
480
+ parser.add_argument(
481
+ "--freeze",
482
+ action="store_true",
483
+ help="Print the digest of the check definitions and exit; writes nothing",
484
+ )
485
+ parser.add_argument(
486
+ "--expect-digest",
487
+ default=None,
488
+ help="Required to verify: the definition digest the harness froze; "
489
+ "a mismatch, or a missing or malformed value, fails closed",
490
+ )
457
491
  args = parser.parse_args()
458
492
 
459
493
  checklist_path = args.checklist
@@ -468,6 +502,26 @@ def main():
468
502
  print(f"Failed to read checklist: {e}", file=sys.stderr)
469
503
  sys.exit(1)
470
504
 
505
+ if args.freeze:
506
+ print(definition_digest(checklist))
507
+ sys.exit(0)
508
+
509
+ expected = args.expect_digest
510
+ if expected is None or not _DIGEST_RE.match(expected):
511
+ print(
512
+ "Refusing to verify: --expect-digest is missing or not a sha256 hex "
513
+ "digest (fail closed)",
514
+ file=sys.stderr,
515
+ )
516
+ sys.exit(2)
517
+ if not hmac.compare_digest(expected, definition_digest(checklist)):
518
+ print(
519
+ "Checklist hash mismatch: check definitions differ from the frozen "
520
+ "digest; refusing to verify",
521
+ file=sys.stderr,
522
+ )
523
+ sys.exit(3)
524
+
471
525
  project_dir = os.getcwd()
472
526
  now = datetime.now(timezone.utc).isoformat()
473
527
 
package/autonomy/edit.sh CHANGED
@@ -73,6 +73,14 @@ cmd_edit() {
73
73
  return "$_rc"
74
74
  }
75
75
 
76
+ # One "<content-hash> <path>" line per untracked, non-ignored file.
77
+ _loki_edit_untracked_hashes() {
78
+ local f
79
+ git ls-files --others --exclude-standard | while IFS= read -r f; do
80
+ [ -f "$f" ] && printf '%s %s\n' "$(git hash-object -- "$f" 2>/dev/null)" "$f"
81
+ done
82
+ }
83
+
76
84
  _cmd_edit_run() {
77
85
  local do_verify=false do_receipt=false do_commit=false dry=false
78
86
  local paths=() change=""
@@ -125,6 +133,8 @@ _cmd_edit_run() {
125
133
  local before_head before_status
126
134
  before_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
127
135
  before_status="$(git status --porcelain)"
136
+ local before_untracked
137
+ before_untracked="$(_loki_edit_untracked_hashes)"
128
138
 
129
139
  local prompt scope=""
130
140
  if [ "${#paths[@]}" -gt 0 ]; then
@@ -184,6 +194,13 @@ print("%s|%s|%s" % (c,err,(d.get("result") or "").strip().replace("\n"," ")[:400
184
194
  # Files touched by this pass: status delta against the pre-run snapshot.
185
195
  local touched
186
196
  touched="$(git status --porcelain | grep -vxF -f <(printf '%s\n' "$before_status") 2>/dev/null | sed 's/^...//' || true)"
197
+ # A file untracked before the run keeps the same porcelain line when edited,
198
+ # so also pick up untracked files whose content hash changed.
199
+ local changed_untracked
200
+ changed_untracked="$(_loki_edit_untracked_hashes | grep -vxF -f <(printf '%s\n' "$before_untracked") 2>/dev/null | sed 's/^[^ ]* //' || true)"
201
+ if [ -n "$changed_untracked" ]; then
202
+ touched="$(printf '%s\n%s\n' "$touched" "$changed_untracked" | sed '/^$/d' | sort -u)"
203
+ fi
187
204
  [ -n "$touched" ] || touched="$(git diff --name-only)"
188
205
 
189
206
  if [ -z "$touched" ]; then
package/autonomy/loki CHANGED
@@ -989,7 +989,7 @@ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
989
989
  import json, sys, hashlib, urllib.request
990
990
  sys.path.insert(0, sys.argv[3])
991
991
  try:
992
- from receipt_jwt import verify_attestation, check_seal_meta, _CRYPTO_AVAILABLE
992
+ from receipt_jwt import verify_attestation, keyset_problem, check_seal_meta, _CRYPTO_AVAILABLE
993
993
  except ImportError:
994
994
  sys.exit(1) # -> "" : cannot check, never a verdict
995
995
  proof = json.load(open(sys.argv[1]))
@@ -1009,7 +1009,8 @@ except Exception:
1009
1009
  # A key set that is not {"keys": [{...}, ...]} -- INCLUDING an empty list -- is
1010
1010
  # a key-set problem: UNCHECKED, never an accusation against the receipt. An
1011
1011
  # empty keyset is an inability to check (no key was published to check
1012
- # against), not evidence of tampering. Checked BEFORE anything below can print
1012
+ # against), not evidence of tampering. The same holds below for a set that
1013
+ # gives zero or several entries, or a non-EdDSA alg, for the token's kid. Checked BEFORE anything below can print
1013
1014
  # "bad", so a bad or empty key set can never read as TAMPERED.
1014
1015
  _keys = jwks.get("keys") if isinstance(jwks, dict) else None
1015
1016
  if not isinstance(_keys, list) or not _keys or not all(isinstance(k, dict) for k in _keys):
@@ -1024,9 +1025,16 @@ if not isinstance(tok, str):
1024
1025
  # dependency is UNCHECKED. Same guard in loki_proof_attestation_check below.
1025
1026
  if not _CRYPTO_AVAILABLE:
1026
1027
  sys.exit(1)
1027
- # The key set is known-good here, so any crash inside verification comes from
1028
- # the token: refuse it. Letting it propagate read as UNCHECKED, which let a
1029
- # forger turn TAMPERED into "install python3 cryptography".
1028
+ # A key set that cannot give exactly one usable key for the token's kid is a
1029
+ # key-set problem, not an accusation: UNCHECKED.
1030
+ try:
1031
+ if keyset_problem(tok, jwks):
1032
+ sys.exit(1)
1033
+ except Exception:
1034
+ print("bad"); sys.exit(0)
1035
+ # Any other crash inside verification comes from the token: refuse it.
1036
+ # Letting it propagate read as UNCHECKED, which let a forger turn TAMPERED
1037
+ # into "install python3 cryptography".
1030
1038
  try:
1031
1039
  ok, claims = verify_attestation(tok, jwks)
1032
1040
  except Exception:
@@ -1127,7 +1135,7 @@ import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]
1127
1135
  import json, sys, os, hashlib, urllib.request
1128
1136
  sys.path.insert(0, sys.argv[3])
1129
1137
  try:
1130
- from receipt_jwt import verify_attestation, check_seal_meta, _CRYPTO_AVAILABLE
1138
+ from receipt_jwt import verify_attestation, keyset_problem, check_seal_meta, _CRYPTO_AVAILABLE
1131
1139
  except ImportError:
1132
1140
  sys.exit(1) # "" : cannot check
1133
1141
  try:
@@ -1164,7 +1172,8 @@ except Exception:
1164
1172
  # a key-set problem: NOT CHECKED, never an accusation. An empty keyset is an
1165
1173
  # inability to check (no key was published to check against), not evidence of
1166
1174
  # tampering. Checked BEFORE anything below can print "bad", so a bad or empty
1167
- # key set can never read as FAILED. Same order in loki_remote_attestation_status
1175
+ # key set (or one that is ambiguous or wrongly tagged for the token's kid, see
1176
+ # keyset_problem) can never read as FAILED. Same order in loki_remote_attestation_status
1168
1177
  # above -- change both.
1169
1178
  _keys = jwks.get("keys") if isinstance(jwks, dict) else None
1170
1179
  if not isinstance(_keys, list) or not _keys or not all(isinstance(k, dict) for k in _keys):
@@ -1180,9 +1189,17 @@ if not isinstance(tok, str):
1180
1189
  # loki_remote_attestation_status above -- change both.
1181
1190
  if not _CRYPTO_AVAILABLE:
1182
1191
  sys.exit(1) # "" : cannot check
1183
- # The key set is known-good here, so any crash inside verification comes from
1184
- # the token: refuse it. Letting it propagate read as NOT CHECKED (exit 2), which
1185
- # let a forger turn FAILED into "could not check".
1192
+ # A key set that cannot give exactly one usable key for the token's kid is a
1193
+ # key-set problem, never an accusation: NOT CHECKED, with the reason on stdout.
1194
+ try:
1195
+ _ksp = keyset_problem(tok, jwks)
1196
+ except Exception:
1197
+ _ksp = None
1198
+ if _ksp:
1199
+ print("keyset: " + _ksp); sys.exit(0)
1200
+ # Any other crash inside verification comes from the token: refuse it.
1201
+ # Letting it propagate read as NOT CHECKED (exit 2), which let a forger turn
1202
+ # FAILED into "could not check".
1186
1203
  try:
1187
1204
  ok, claims = verify_attestation(tok, jwks)
1188
1205
  except Exception:
@@ -15196,8 +15213,9 @@ EOF
15196
15213
 
15197
15214
  # FC-EGRESS-AUDIT-HAND-LISTED: the registry of every env var that names an outbound
15198
15215
  # host. _loki_airgap_audit walks it, so a configured webhook, tracker, remote or model
15199
- # gateway is named in the audit. tests/test-airgap-commands.sh greps autonomy/ and
15200
- # loki-ts/src for LOKI_*_(URL|HOST|ENDPOINT|WEBHOOK) readers and fails on any name that
15216
+ # gateway is named in the audit. tests/test-airgap-commands.sh greps autonomy/,
15217
+ # loki-ts/src, providers, dashboard and web-app for LOKI_*_(URL|HOST|ENDPOINT|WEBHOOK|ISSUER)
15218
+ # and DATABASE_URL readers and fails on any name that
15201
15219
  # is in neither list. Entry: NAME|what it reaches.
15202
15220
  _LOKI_AIRGAP_EGRESS_ENV=(
15203
15221
  "LOKI_WEBHOOK_URL|custom webhook notifications"
@@ -15216,6 +15234,9 @@ _LOKI_AIRGAP_EGRESS_ENV=(
15216
15234
  "JIRA_BASE_URL|Jira write-back (alias of LOKI_JIRA_URL)"
15217
15235
  "LOKI_LINEAR_URL|Linear write-back"
15218
15236
  "LOKI_MODEL_BASE_URL|model route base URL"
15237
+ "LOKI_AUDIT_SYSLOG_HOST|dashboard audit log forwarding to a remote syslog server"
15238
+ "LOKI_OIDC_ISSUER|dashboard OIDC sign-in (fetches the issuer discovery document and JWKS keys)"
15239
+ "DATABASE_URL|web-app Postgres database server (unset to use local file storage)"
15219
15240
  )
15220
15241
  # Names the guard skips because they never name an outbound host. NAME|reason.
15221
15242
  _LOKI_AIRGAP_EGRESS_NOT=(
@@ -15242,6 +15263,14 @@ _LOKI_AIRGAP_EGRESS_NOT=(
15242
15263
  "LOKI_LAST_GIST_URL|internal variable carrying a gist link this run already created"
15243
15264
  "LOKI_HOSTED_BODY|internal temp-file path"
15244
15265
  "LOKI_HOSTED_EP|internal copy of LOKI_HOSTED_ENDPOINT"
15266
+ "LOKI_DASHBOARD_ALLOWED_HOSTS|the dashboard's own inbound Host-header allowlist, not a destination"
15267
+ "LOKI_HOST_CHILD_PATH|a filesystem path for the host-guard sandbox, not a host"
15268
+ "LOKI_HOST_PROTECTED_ROOT|a filesystem path for the host-guard sandbox, not a host"
15269
+ "LOKI_HOST_RESERVED_PORTS|local ports the host-guard sandbox reserves, not a destination"
15270
+ "LOKI_HOST_RUNTIME_READ_PATHS|filesystem paths for the host-guard sandbox, not a host"
15271
+ "LOKI_HOST_RUNTIME_WRITE_PATHS|filesystem paths for the host-guard sandbox, not a host"
15272
+ "LOKI_HOST_SEATBELT|a boolean switch for the host-guard sandbox, not a host"
15273
+ "LOKI_HOST_SEATBELT_PROBE_SHA|a hash of the host-guard sandbox probe, not a host"
15245
15274
  )
15246
15275
 
15247
15276
  # _loki_airgap_configured_egress -- one TSV line per configured outbound host that is
@@ -21913,6 +21942,18 @@ def _resolve_session_pin(alias):
21913
21942
  _m = 'opus'
21914
21943
  else: # development (and the '*' fallthrough)
21915
21944
  _m = _provider_model_development()
21945
+ # Explicit top / floor setup, mirrors claude.sh resolve_model_for_tier: a
21946
+ # planning pin dispatches opus, a fast pin haiku (skipped when
21947
+ # LOKI_ALLOW_HAIKU is explicitly set to anything but 'true'), unless legacy
21948
+ # tier switching or an explicit per-tier pin applies.
21949
+ if os.environ.get('LOKI_LEGACY_TIER_SWITCHING') != 'true':
21950
+ if (_pin_tier == 'planning' and alias == 'planning'
21951
+ and not (os.environ.get('LOKI_CLAUDE_MODEL_PLANNING') or os.environ.get('LOKI_MODEL_PLANNING'))):
21952
+ _m = 'opus'
21953
+ elif (_pin_tier == 'fast' and alias == 'fast'
21954
+ and not (os.environ.get('LOKI_CLAUDE_MODEL_FAST') or os.environ.get('LOKI_MODEL_FAST'))
21955
+ and os.environ.get('LOKI_ALLOW_HAIKU', 'true') == 'true'):
21956
+ _m = 'haiku'
21916
21957
  # Apply the shared LOKI_MAX_TIER clamp with the REAL tier and resolved model
21917
21958
  # (loki_apply_max_tier_clamp(model, tier)), NOT the alias==alias override
21918
21959
  # convention. This is the one place where the two paths legitimately diverge
@@ -23515,12 +23556,13 @@ main() {
23515
23556
  # - Non-TTY/CI without --yes (and no LOKI_ULTRAREVIEW=1): REFUSE with exit 2
23516
23557
  # and ZERO ultrareview calls (the no-silent-bill guard). Never hang on read.
23517
23558
  # - --yes or LOKI_ULTRAREVIEW=1 proceeds without prompting (this command only).
23518
- # Args: <format> <assume_yes> <timeout_minutes> <target>
23559
+ # Args: <format> <assume_yes> <timeout_minutes> <target> <trust_workspace>
23519
23560
  _review_ultra() {
23520
23561
  local fmt="${1:-text}"
23521
23562
  local assume_yes="${2:-false}"
23522
23563
  local timeout_min="${3:-}"
23523
23564
  local target="${4:-}"
23565
+ local trust_ws="${5:-false}"
23524
23566
 
23525
23567
  # Make the ultrareview gate predicates available even though autonomy/loki
23526
23568
  # does not source claude-flags.sh globally (same on-demand pattern as
@@ -23605,14 +23647,31 @@ _review_ultra() {
23605
23647
  if [ -n "$timeout_min" ]; then
23606
23648
  ur_argv+=("--timeout" "$timeout_min")
23607
23649
  fi
23650
+ # Claude Code 2.1.309+ needs --trust-workspace for scripted runs. Forward it
23651
+ # only when the user passed it AND the CLI lists it; never trust by default.
23652
+ local ur_trust_ok=false
23653
+ if { claude ultrareview --help 2>&1; claude --help 2>&1; } | grep -q -- '--trust-workspace'; then
23654
+ ur_trust_ok=true
23655
+ fi
23656
+ if [ "$trust_ws" = true ]; then
23657
+ if [ "$ur_trust_ok" = true ]; then
23658
+ ur_argv+=("--trust-workspace")
23659
+ else
23660
+ echo "Note: this claude CLI does not list --trust-workspace; not forwarding it." >&2
23661
+ fi
23662
+ fi
23608
23663
  if [ -n "$target" ]; then
23609
23664
  ur_argv+=("$target")
23610
23665
  fi
23611
23666
 
23612
23667
  echo -e "${DIM}Running: claude ${ur_argv[*]}${NC}" >&2
23613
23668
  echo "" >&2
23614
- claude "${ur_argv[@]}"
23615
- return $?
23669
+ local ur_rc=0
23670
+ claude "${ur_argv[@]}" || ur_rc=$?
23671
+ if [ "$ur_rc" -ne 0 ] && [ "$trust_ws" != true ] && [ "$ur_trust_ok" = true ]; then
23672
+ echo "If claude reported an untrusted folder, re-run: loki review --ultra --yes --trust-workspace" >&2
23673
+ fi
23674
+ return "$ur_rc"
23616
23675
  }
23617
23676
 
23618
23677
  # v7.38.0: optional native Claude Code Dynamic Workflow passthrough
@@ -23800,6 +23859,7 @@ cmd_review() {
23800
23859
  local review_format="text"
23801
23860
  local review_severity="all"
23802
23861
  local review_ultra=false
23862
+ local review_trust_ws=false
23803
23863
  local review_assume_yes=false
23804
23864
  local review_timeout=""
23805
23865
  local review_risk=false
@@ -23836,6 +23896,8 @@ cmd_review() {
23836
23896
  echo " 'loki review <pr-number> --risk' also works. Use --json"
23837
23897
  echo " --yes, -y Skip the --ultra confirmation prompt"
23838
23898
  echo " --timeout <minutes> --ultra: max minutes to wait (default 30)"
23899
+ echo " --trust-workspace --ultra: forward claude's --trust-workspace (needed for"
23900
+ echo " scripted runs in an untrusted folder; never default)"
23839
23901
  echo " --help, -h Show this help"
23840
23902
  echo ""
23841
23903
  echo "Exit codes:"
@@ -23857,6 +23919,7 @@ cmd_review() {
23857
23919
  return 0
23858
23920
  ;;
23859
23921
  --ultra) review_ultra=true; shift ;;
23922
+ --trust-workspace) review_trust_ws=true; shift ;;
23860
23923
  --risk) review_risk=true; shift ;;
23861
23924
  --json) review_format="json"; shift ;;
23862
23925
  --yes|-y) review_assume_yes=true; shift ;;
@@ -23910,7 +23973,7 @@ cmd_review() {
23910
23973
  # below: --ultra is a different operation (paid cloud), not a flag that
23911
23974
  # augments the local static analysis.
23912
23975
  if [ "$review_ultra" = true ]; then
23913
- _review_ultra "$review_format" "$review_assume_yes" "$review_timeout" "$review_target"
23976
+ _review_ultra "$review_format" "$review_assume_yes" "$review_timeout" "$review_target" "$review_trust_ws"
23914
23977
  return $?
23915
23978
  fi
23916
23979
 
@@ -39218,6 +39281,13 @@ print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))
39218
39281
  _pv_rc=1
39219
39282
  _pv_attestation_field="ABSENT"
39220
39283
  ;;
39284
+ keyset:*)
39285
+ echo -e "${YELLOW}attestation: NOT CHECKED -- ${_pv_att#keyset: }.${NC}" >&2
39286
+ echo " The key set at ${_pv_jwks} cannot give one usable key for this receipt." >&2
39287
+ echo " This is not a failure of the receipt. Nothing was proven either way (exit 2)." >&2
39288
+ if [ "$_pv_rc" -eq 0 ]; then _pv_rc=2; fi
39289
+ _pv_attestation_field="NOT CHECKED"
39290
+ ;;
39221
39291
  *)
39222
39292
  # Unreachable key set, unreadable file, missing python
39223
39293
  # deps. NOT a verdict: an absent measurement must never
@@ -106,6 +106,10 @@ fi
106
106
  # Internal state
107
107
  CHECKLIST_DIR=""
108
108
  CHECKLIST_FILE=""
109
+ # Digest of the check definitions, frozen by the harness the first time it sees
110
+ # the checklist and held only in this process's memory (never on disk, where an
111
+ # agent could rewrite it). Every verify passes it as --expect-digest.
112
+ CHECKLIST_FROZEN_DIGEST=""
109
113
  CHECKLIST_RESULTS_FILE=""
110
114
  CHECKLIST_LAST_VERIFY_ITERATION=0
111
115
  # Path to the spec/PRD that drove this checklist. Used by the acceptance-oracle
@@ -1009,6 +1013,27 @@ except Exception:
1009
1013
  # Verification
1010
1014
  #===============================================================================
1011
1015
 
1016
+ # Freeze the check definitions once. Idempotent: a second call keeps the first
1017
+ # digest, so an edit made after the freeze can never become the new baseline.
1018
+ checklist_freeze_once() {
1019
+ [ "$CHECKLIST_ENABLED" = "true" ] || return 0
1020
+ [ -z "$CHECKLIST_FROZEN_DIGEST" ] || return 0
1021
+ [ -n "$CHECKLIST_FILE" ] && [ -f "$CHECKLIST_FILE" ] || return 0
1022
+
1023
+ local script_dir verify_script _cl_py digest
1024
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1025
+ verify_script="${script_dir}/checklist-verify.py"
1026
+ [ -f "$verify_script" ] || return 1
1027
+ _cl_py="$(_loki_snapshot_py_tool)" || return 1
1028
+ digest="$("$_cl_py" -I -S "$verify_script" --checklist "$CHECKLIST_FILE" --freeze 2>/dev/null)" || return 1
1029
+ case "$digest" in
1030
+ *[!0-9a-f]* | "") return 1 ;;
1031
+ esac
1032
+ [ "${#digest}" -eq 64 ] || return 1
1033
+ CHECKLIST_FROZEN_DIGEST="$digest"
1034
+ return 0
1035
+ }
1036
+
1012
1037
  checklist_verify() {
1013
1038
  if [ "$CHECKLIST_ENABLED" != "true" ]; then
1014
1039
  return 0
@@ -1045,11 +1070,22 @@ checklist_verify() {
1045
1070
  return 1
1046
1071
  fi
1047
1072
 
1073
+ if ! checklist_freeze_once; then
1074
+ log_warn "checklist verification skipped: could not freeze the check definitions (fail closed)"
1075
+ return 1
1076
+ fi
1077
+
1048
1078
  log_step "Running PRD checklist verification..."
1049
1079
 
1050
- "$_cl_py" -I -S "$verify_script" \
1080
+ local _cl_rc=0 _cl_err
1081
+ _cl_err="$("$_cl_py" -I -S "$verify_script" \
1051
1082
  --checklist "$CHECKLIST_FILE" \
1052
- --timeout "$CHECKLIST_TIMEOUT" 2>/dev/null || true
1083
+ --expect-digest "$CHECKLIST_FROZEN_DIGEST" \
1084
+ --timeout "$CHECKLIST_TIMEOUT" 2>&1 >/dev/null)" || _cl_rc=$?
1085
+ if [ "$_cl_rc" -ne 0 ]; then
1086
+ log_warn "checklist verification refused (rc=$_cl_rc): ${_cl_err##*$'\n'}"
1087
+ return "$_cl_rc"
1088
+ fi
1053
1089
 
1054
1090
  CHECKLIST_LAST_VERIFY_ITERATION="${ITERATION_COUNT:-0}"
1055
1091