loki-mode 9.8.0 → 9.12.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/README.md +19 -14
  2. package/SKILL.md +3 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +122 -1
  5. package/autonomy/run.sh +49 -2
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/api_evidence.py +411 -0
  8. package/dashboard/api_operator.py +283 -0
  9. package/dashboard/api_phases.py +262 -0
  10. package/dashboard/api_releases.py +242 -0
  11. package/dashboard/api_runs.py +477 -0
  12. package/dashboard/api_tests.py +444 -0
  13. package/dashboard/api_v2.py +47 -1
  14. package/dashboard/server.py +54 -0
  15. package/dashboard/static/index.html +246 -135
  16. package/docs/ARCHITECTURE-OVERVIEW.md +5 -3
  17. package/docs/CAPABILITY-BACKLOG.md +53 -0
  18. package/docs/COMPARISON.md +2 -2
  19. package/docs/COMPETITIVE-ANALYSIS.md +1 -1
  20. package/docs/COMPETITIVE-SCORECARD.md +422 -0
  21. package/docs/DASHBOARD-9.12-EVIDENCE.md +97 -0
  22. package/docs/DASHBOARD-ARCHITECTURE.md +423 -0
  23. package/docs/DEMOS.md +21 -23
  24. package/docs/HANDOFF-2026-08-03.md +439 -0
  25. package/docs/INSTALLATION.md +17 -10
  26. package/docs/OUTCOME-FRONTIER.md +536 -0
  27. package/docs/PROMPT-ABLATION-RESULT.md +97 -0
  28. package/docs/TOOLS.md +800 -0
  29. package/docs/alternative-installations.md +2 -3
  30. package/docs/audit-logging.md +44 -35
  31. package/docs/authentication.md +13 -2
  32. package/docs/authorization.md +87 -81
  33. package/docs/git-workflow.md +6 -3
  34. package/docs/metrics.md +15 -16
  35. package/docs/network-security.md +16 -13
  36. package/docs/openclaw-integration.md +36 -556
  37. package/docs/show-hn-post.md +2 -2
  38. package/docs/siem-integration.md +39 -36
  39. package/loki-ts/dist/loki.js +18 -18
  40. package/mcp/__init__.py +1 -1
  41. package/package.json +2 -2
  42. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  43. package/references/confidence-routing.md +18 -1
  44. package/references/invariant-checks.md +13 -8
  45. package/references/magic-rarv-integration.md +0 -1
  46. package/references/multi-provider.md +27 -5
  47. package/skills/healing.md +4 -2
  48. package/tools/audit-docs.py +488 -0
  49. package/tools/baseline-pin.py +19 -1
  50. package/tools/calibration-audit.py +523 -0
  51. package/tools/ci-gate.py +19 -1
  52. package/tools/cost-forecast.py +344 -0
  53. package/tools/cost-guard.py +19 -1
  54. package/tools/cost-history.py +19 -1
  55. package/tools/cost-per-outcome.py +394 -0
  56. package/tools/estimate-run.py +19 -1
  57. package/tools/evidence-freshness.py +307 -0
  58. package/tools/gate-init.py +19 -1
  59. package/tools/gate-report.py +19 -1
  60. package/tools/gate-simulate.py +570 -0
  61. package/tools/gate-trend.py +354 -0
  62. package/tools/model-advisor.py +52 -1
  63. package/tools/policy-load.py +19 -1
  64. package/tools/prompt-cost.py +363 -0
  65. package/tools/prompt-diff.py +448 -0
  66. package/tools/prompt-lint.py +448 -0
  67. package/tools/receipt-bundle.py +72 -2
  68. package/tools/receipt-diff.py +19 -1
  69. package/tools/receipt-find.py +19 -1
  70. package/tools/receipt-stats.py +380 -0
  71. package/tools/receipt-timeline.py +478 -0
  72. package/tools/receipt-verify-batch.py +291 -0
  73. package/tools/run-replay.py +19 -1
  74. package/tools/signing-status.py +19 -1
  75. package/tools/token-guard.py +19 -1
  76. package/tools/token-tax.py +375 -0
  77. package/tools/tool-index.py +19 -1
  78. package/tools/verification-tax.py +277 -0
  79. package/tools/verify-chain.py +361 -0
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only evidence API: receipts (and the artifact files beside them).
3
+
4
+ WHY THIS EXISTS. The dashboard has 138 routes and ZERO of them touch receipts,
5
+ which are the product's differentiator: there is currently no way to show the
6
+ evidence chain at all. This module is the data layer for that, and nothing
7
+ more.
8
+
9
+ WHAT IS DELIBERATELY NOT HERE. No hashing, no receipt parsing, no verdict
10
+ precedence. Every one of those already has exactly one definition upstream and
11
+ a second copy is how the two drift:
12
+
13
+ verify(), verify_integrity() autonomy/lib/proof-verify.py
14
+ receipt_state() tools/receipt-bundle.py (3-state classifier)
15
+ find_receipts() tools/receipt-bundle.py (the walker)
16
+ measured_cost() tools/receipt-diff.py, via receipt-bundle
17
+
18
+ This file maps their output onto a UI shape and adds ONE fact they do not
19
+ carry: freshness. That is the entire diff.
20
+
21
+ THE STATES ARE NOT COLLAPSED. A receipt that cannot be verified reads
22
+ UNVERIFIABLE with the reason that made it so. It never reads "ok", and it is
23
+ never dropped from the list -- an absent row is indistinguishable from a clean
24
+ one, which is the failure mode receipts exist to prevent.
25
+
26
+ VERIFIED every axis was checked and passed
27
+ FAILED an axis was checked and said no
28
+ UNVERIFIABLE an axis could NOT be checked here, with its reason
29
+ EMPTY the walk found nothing (a verdict, never a pass)
30
+
31
+ FRESHNESS IS MEASURED OR UNKNOWN, NEVER ZERO. `freshness_s` is None when
32
+ generated_at is absent or unparseable, and `freshness_source` says which. Zero
33
+ would read as "generated this instant", which is a fabricated observation.
34
+ Note generated_at ends in "Z", which datetime.fromisoformat rejects before
35
+ Python 3.11 -- handled below, since this must behave the same on both.
36
+
37
+ EVERY RESULT CARRIES ITS OWN SOURCE AND ERROR STATE. `source` names the file or
38
+ walk the numbers came from, `checked_at` when, and `error` is a string whenever
39
+ the answer could not be produced -- never an empty list standing in for a
40
+ failed read.
41
+ """
42
+
43
+ import datetime
44
+ import importlib.util
45
+ import os
46
+ import pathlib
47
+ import sys
48
+
49
+ # A stale .pyc for a hyphenated module loaded by path makes mutation probes
50
+ # report FALSE failures (the probe edits the source, the loader serves the old
51
+ # bytecode). Must be set before any loader below runs.
52
+ sys.dont_write_bytecode = True
53
+
54
+ _ROOT = pathlib.Path(__file__).resolve().parents[1]
55
+
56
+
57
+ def _load(name, path):
58
+ spec = importlib.util.spec_from_file_location(name, path)
59
+ mod = importlib.util.module_from_spec(spec)
60
+ spec.loader.exec_module(mod)
61
+ return mod
62
+
63
+
64
+ _pv = _load("proof_verify", _ROOT / "autonomy" / "lib" / "proof-verify.py")
65
+ _rb = _load("receipt_bundle", _ROOT / "tools" / "receipt-bundle.py")
66
+
67
+ verify = _pv.verify
68
+ receipt_state = _rb.receipt_state
69
+ find_receipts = _rb.find_receipts
70
+ find_receipts_bounded = _rb.find_receipts_bounded
71
+ measured_cost = _rb.measured_cost
72
+
73
+ VERIFIED = _rb.VERIFIED
74
+ FAILED = _rb.FAILED
75
+ UNVERIFIABLE = _rb.UNVERIFIABLE
76
+ EMPTY = _rb.EMPTY
77
+
78
+
79
+ def _now():
80
+ return datetime.datetime.now(datetime.timezone.utc)
81
+
82
+
83
+ def _iso(dt):
84
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
85
+
86
+
87
+ def _parse_generated_at(value):
88
+ """(datetime | None, reason). Reason is non-empty exactly when dt is None.
89
+
90
+ fromisoformat rejects a trailing "Z" before Python 3.11, so it is swapped
91
+ for the explicit +00:00 offset rather than sliced away -- dropping it would
92
+ silently reinterpret a UTC stamp as local time and skew every freshness
93
+ number by the host's offset.
94
+ """
95
+ if not isinstance(value, str) or not value:
96
+ return None, "receipt records no generated_at timestamp"
97
+ try:
98
+ dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
99
+ except ValueError:
100
+ return None, "generated_at is not an ISO-8601 timestamp: %r" % value
101
+ if dt.tzinfo is None:
102
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
103
+ return dt, ""
104
+
105
+
106
+ def _freshness(proof, now=None):
107
+ """How old the receipt says it is, in seconds, or UNKNOWN with a reason.
108
+
109
+ A negative age (receipt stamped in the future) is reported as-is rather
110
+ than clamped to 0: clock skew is a real condition an operator needs to see,
111
+ and clamping would disguise it as a fresh receipt.
112
+ """
113
+ now = now or _now()
114
+ generated_at = proof.get("generated_at") if isinstance(proof, dict) else None
115
+ dt, reason = _parse_generated_at(generated_at)
116
+ if dt is None:
117
+ return {
118
+ "generated_at": generated_at if isinstance(generated_at, str) else None,
119
+ "freshness_s": None,
120
+ "freshness_source": "UNKNOWN: " + reason,
121
+ }
122
+ return {
123
+ "generated_at": generated_at,
124
+ "freshness_s": (now - dt).total_seconds(),
125
+ "freshness_source": "receipt generated_at vs wall clock at read time",
126
+ }
127
+
128
+
129
+ def _cost_usd(proof):
130
+ """Measured cost, or None. None means UNMEASURED, not free.
131
+
132
+ `is not None`, never truthiness: a genuinely measured $0.00 is a real
133
+ observation and must survive as 0.0.
134
+ """
135
+ try:
136
+ cost = measured_cost(proof)
137
+ except Exception:
138
+ return None
139
+ if cost is None:
140
+ return None
141
+ return cost.get("cost_usd")
142
+
143
+
144
+ def list_receipts(workspace, repo_dir=".", max_entries=None,
145
+ max_seconds=None):
146
+ """Every receipt under `workspace`, classified. Pure: no writes, no network.
147
+
148
+ Returns a list of rows, one per receipt found, each carrying:
149
+ path, verdict, hash_ok, cost_usd|None, measured, generated_at,
150
+ freshness_s|None, freshness_source, reason
151
+
152
+ `measured` is about COST specifically -- whether cost_usd is a real
153
+ observation -- and is independent of the verdict. A FAILED receipt can
154
+ still have measured its own spend, and hiding that would understate what a
155
+ bad run cost.
156
+
157
+ A row whose receipt will not load is kept with verdict UNVERIFIABLE and the
158
+ loader's own reason. It is never dropped: a missing row and a clean row
159
+ look identical to a reader.
160
+
161
+ ponytail: O(N) receipts x several git subprocesses each, over an unbounded
162
+ rglob, and integrity is computed twice per receipt (verify() does it
163
+ internally, then verify_integrity() again for hash_ok). Both are deliberate:
164
+ the alternative is re-deriving the verdict precedence ladder here, which is
165
+ exactly the drift this module exists to avoid. Paginate, or cache by
166
+ (path, mtime), if a workspace outgrows an interactive request.
167
+ """
168
+ root = pathlib.Path(workspace)
169
+ if not root.is_dir():
170
+ return []
171
+ paths, _truncated = find_receipts_bounded(root, max_entries=max_entries,
172
+ max_seconds=max_seconds)
173
+ return _rows_for_paths(paths, repo_dir)
174
+
175
+
176
+ def _rows_for_paths(paths, repo_dir="."):
177
+ """Classify an ALREADY-WALKED list of receipt paths.
178
+
179
+ Split out from list_receipts so receipts_report can do the walk itself and
180
+ keep the truncation reason as a local. Sharing this loop means the two
181
+ entry points cannot drift into classifying the same receipt differently.
182
+ """
183
+ rows = []
184
+ now = _now()
185
+ for path in paths:
186
+ state, reason = receipt_state(path, repo_dir)
187
+
188
+ try:
189
+ proof = _pv._load_proof(str(path))
190
+ except Exception as exc:
191
+ # Classified but unreadable: keep the row, say why, and leave every
192
+ # derived field UNKNOWN rather than defaulting it to a number.
193
+ rows.append({
194
+ "path": str(path),
195
+ "verdict": state,
196
+ "hash_ok": None,
197
+ "cost_usd": None,
198
+ "measured": False,
199
+ "generated_at": None,
200
+ "freshness_s": None,
201
+ "freshness_source": "UNKNOWN: receipt could not be read: %s" % exc,
202
+ "reason": reason or str(exc),
203
+ })
204
+ continue
205
+
206
+ # hash_ok is the integrity axis alone and is git-independent, so it
207
+ # stays meaningful even where the repo checks cannot run.
208
+ try:
209
+ hash_ok = bool(_pv.verify_integrity(proof).get("hash_ok"))
210
+ except Exception:
211
+ hash_ok = None
212
+
213
+ cost = _cost_usd(proof)
214
+ row = {
215
+ "path": str(path),
216
+ "verdict": state,
217
+ "hash_ok": hash_ok,
218
+ "cost_usd": cost,
219
+ "measured": cost is not None,
220
+ "reason": reason,
221
+ }
222
+ row.update(_freshness(proof, now))
223
+ rows.append(row)
224
+
225
+ return rows
226
+
227
+
228
+ def receipts_report(workspace, repo_dir=".", max_entries=None,
229
+ max_seconds=None):
230
+ """list_receipts plus the batch verdict, source, and error state.
231
+
232
+ The verdict is the WEAKEST state present (imported, never restated), and an
233
+ empty walk is EMPTY -- its own verdict, not a vacuous pass. "We found
234
+ nothing wrong" is not a claim anyone is entitled to make about a tree they
235
+ never opened.
236
+ """
237
+ root = pathlib.Path(workspace)
238
+ checked_at = _iso(_now())
239
+ base = {
240
+ "report": "loki-dashboard-receipts/v1",
241
+ "source": "walk of proof.json under %s, re-verified from %s" % (
242
+ os.path.abspath(str(workspace)), os.path.abspath(repo_dir)),
243
+ "checked_at": checked_at,
244
+ "freshness_source": "computed at read time; no cache",
245
+ "receipts": [],
246
+ "count": 0,
247
+ # Seeded here, not only on the success path. A consumer must be able to
248
+ # read the same keys on BOTH paths; a counts block that exists only when
249
+ # the walk succeeded makes report["counts"][FAILED] raise KeyError
250
+ # exactly when something went wrong, which is when it is needed most.
251
+ "counts": {FAILED: 0, UNVERIFIABLE: 0, VERIFIED: 0},
252
+ "verdict": EMPTY,
253
+ # Seeded here, not only on the success path, for the same reason
254
+ # `counts` is: a consumer reading report["truncated"] must not hit a
255
+ # KeyError on the branch where the walk could not run at all.
256
+ "truncated": None,
257
+ "error": None,
258
+ }
259
+ if not root.is_dir():
260
+ base["error"] = "workspace not found or not a directory: %s" % workspace
261
+ return base
262
+
263
+ # The walk is done HERE, not inside list_receipts, so the truncation
264
+ # reason is a plain local rather than shared mutable state. Stashing it on
265
+ # the function object would race between concurrent requests and could
266
+ # report one caller's complete walk as another's partial one.
267
+ _paths, truncated = find_receipts_bounded(root, max_entries=max_entries,
268
+ max_seconds=max_seconds)
269
+ rows = _rows_for_paths(_paths, repo_dir)
270
+ base["truncated"] = truncated
271
+ base["receipts"] = rows
272
+ base["count"] = len(rows)
273
+ base["verdict"] = _rb.rollup([r["verdict"] for r in rows])
274
+ base["counts"] = {s: sum(1 for r in rows if r["verdict"] == s)
275
+ for s in (FAILED, UNVERIFIABLE, VERIFIED)}
276
+ if truncated:
277
+ # A partial walk cannot certify a sequence. The verdict is held down
278
+ # to UNVERIFIABLE rather than reported as VERIFIED over the subset
279
+ # that happened to be reached before the limit.
280
+ if base["verdict"] == VERIFIED:
281
+ base["verdict"] = UNVERIFIABLE
282
+ base["error"] = ("the receipt walk was truncated (%s), so this audit "
283
+ "is PARTIAL and cannot certify the workspace"
284
+ % truncated)
285
+ elif not rows:
286
+ base["error"] = ("no receipts found under this workspace, so nothing "
287
+ "was audited. Zero receipts is not a passing audit.")
288
+ return base
289
+
290
+
291
+ def receipt_detail(path, repo_dir="."):
292
+ """The verifier's OWN output, verbatim, plus freshness and source.
293
+
294
+ verify()'s dict is passed through unmodified under `verification` -- not
295
+ reshaped, not filtered, not summarised. Any reshaping here would be a
296
+ second opinion about what a receipt says, and the entire point is that
297
+ there is one.
298
+ """
299
+ checked_at = _iso(_now())
300
+ out = {
301
+ "report": "loki-dashboard-receipt-detail/v1",
302
+ "path": str(path),
303
+ "source": "autonomy/lib/proof-verify.py verify(), re-run at read time",
304
+ "checked_at": checked_at,
305
+ "verification": None,
306
+ "verdict": UNVERIFIABLE,
307
+ "reason": "",
308
+ "generated_at": None,
309
+ "freshness_s": None,
310
+ "freshness_source": "UNKNOWN: receipt not read",
311
+ # Same rule as counts above: every early return below must still carry
312
+ # these keys, and UNMEASURED is None/False -- never 0.0/True.
313
+ "cost_usd": None,
314
+ "measured": False,
315
+ "error": None,
316
+ }
317
+
318
+ p = pathlib.Path(path)
319
+ if not p.is_file():
320
+ out["error"] = ("no such receipt: %s -- not present on disk" % path)
321
+ out["reason"] = out["error"]
322
+ out["freshness_source"] = "UNKNOWN: " + out["error"]
323
+ return out
324
+
325
+ try:
326
+ out["verification"] = verify(str(p), repo_dir)
327
+ except Exception as exc:
328
+ out["error"] = "the receipt could not be verified: %s" % exc
329
+ out["reason"] = out["error"]
330
+ out["freshness_source"] = "UNKNOWN: " + out["error"]
331
+ return out
332
+
333
+ # The verdict comes from the shared classifier, NOT from verify()'s `ok`.
334
+ # `ok` is False both for a receipt that failed a check and for one whose
335
+ # checks could not run, and collapsing those two is the distinction this
336
+ # whole surface exists to preserve.
337
+ state, reason = receipt_state(str(p), repo_dir)
338
+ out["verdict"] = state
339
+ out["reason"] = reason
340
+
341
+ try:
342
+ proof = _pv._load_proof(str(p))
343
+ except Exception as exc:
344
+ out["freshness_source"] = "UNKNOWN: receipt could not be read: %s" % exc
345
+ return out
346
+
347
+ out.update(_freshness(proof))
348
+ out["cost_usd"] = _cost_usd(proof)
349
+ out["measured"] = out["cost_usd"] is not None
350
+ return out
351
+
352
+
353
+ def list_artifacts(workspace):
354
+ """Real files under `workspace`/artifacts/, with mtime-derived freshness.
355
+
356
+ ponytail: an enumeration of what is actually on disk, nothing more. No
357
+ artifact taxonomy, no type inference, no parsing of contents -- there is no
358
+ artifact schema in this repo to be faithful to, and inventing one would be
359
+ fabricating structure the files do not have.
360
+
361
+ ponytail: unbounded rglob, stat per file, no pagination. Fine for the ~75
362
+ files this repo has; add a limit + offset if a workspace outgrows it.
363
+ """
364
+ root = pathlib.Path(workspace) / "artifacts"
365
+ now = _now()
366
+ out = {
367
+ "report": "loki-dashboard-artifacts/v1",
368
+ "source": "directory listing of %s" % os.path.abspath(str(root)),
369
+ "checked_at": _iso(now),
370
+ "freshness_source": "filesystem mtime vs wall clock at read time",
371
+ "artifacts": [],
372
+ "count": 0,
373
+ "error": None,
374
+ }
375
+ if not root.is_dir():
376
+ out["error"] = "no artifacts directory under this workspace: %s" % root
377
+ return out
378
+
379
+ rows = []
380
+ for p in sorted(root.rglob("*")):
381
+ if not p.is_file():
382
+ continue
383
+ try:
384
+ st = p.stat()
385
+ except OSError as exc:
386
+ # Named and counted, never skipped -- an unreadable artifact is a
387
+ # fact about the tree, not an absence.
388
+ rows.append({
389
+ "path": str(p),
390
+ "size_bytes": None,
391
+ "modified_at": None,
392
+ "freshness_s": None,
393
+ "freshness_source": "UNKNOWN: could not stat: %s" % exc,
394
+ })
395
+ continue
396
+ mtime = datetime.datetime.fromtimestamp(
397
+ st.st_mtime, datetime.timezone.utc)
398
+ rows.append({
399
+ "path": str(p),
400
+ "size_bytes": st.st_size,
401
+ "modified_at": _iso(mtime),
402
+ "freshness_s": (now - mtime).total_seconds(),
403
+ "freshness_source": "filesystem mtime",
404
+ })
405
+
406
+ out["artifacts"] = rows
407
+ out["count"] = len(rows)
408
+ if not rows:
409
+ out["error"] = ("artifacts directory exists but contains no files, so "
410
+ "nothing was listed.")
411
+ return out
@@ -0,0 +1,283 @@
1
+ """The operator surface: the four evidence readers, reachable over HTTP.
2
+
3
+ WHAT THIS FIXES. dashboard/api_runs.py, api_evidence.py, api_tests.py and
4
+ api_releases.py read real Loki lifecycle state from the filesystem, and every
5
+ one of them was written with a strict envelope contract: an empty result
6
+ carries a REASON, an unmeasured number reads None rather than 0, and the
7
+ sources consulted are named so any row can be audited.
8
+
9
+ None of them had an APIRouter. They were libraries that only the test suite
10
+ imported -- four readers, zero of them reachable by a user. Mounting them is
11
+ the whole point of this module.
12
+
13
+ WHY ONE ROUTER AND NOT FOUR. Four separately-mounted routers would each need
14
+ their own prefix decision, their own error convention and their own auth
15
+ story, and they would drift. One router with one convention is the thing a
16
+ later reader can keep correct.
17
+
18
+ WHAT THIS DELIBERATELY DOES NOT EXPOSE. There is no `/runs` LIST route here,
19
+ even though api_runs.list_runs is the most obviously useful function in the
20
+ set. api_v2 already serves GET /api/v2/runs and already falls back to this
21
+ exact adapter when its SQL store is empty. Adding a second list surface would
22
+ recreate precisely the divergence that fallback was written to avoid: two
23
+ endpoints answering "what runs exist" from different stores, disagreeing, with
24
+ no way for a caller to know which one lied. The per-run detail route below has
25
+ no v2 equivalent, so it adds a surface rather than forking one.
26
+
27
+ THE ENVELOPE IS PASSED THROUGH UNCHANGED. Every route returns exactly what the
28
+ reader returned. It is tempting to unwrap `{"runs": [...]}` into a bare list
29
+ for convenience, and that would silently discard `reason`, `source` and
30
+ `freshness_s` -- the three fields that separate "there are no runs" from "I
31
+ could not read the runs". A caller that cannot tell those apart will render
32
+ the second as the first, which is the exact failure this codebase treats as
33
+ worse than an error.
34
+
35
+ READ-ONLY BY CONSTRUCTION. Every route is a GET and every underlying reader
36
+ only opens files. Nothing here mutates a run, and nothing here shells out.
37
+ """
38
+
39
+ import logging
40
+ import os
41
+ from typing import Optional
42
+
43
+ from fastapi import APIRouter, Depends, HTTPException, Query
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ router = APIRouter(prefix="/api/operator", tags=["operator"])
48
+
49
+
50
+ # Auth is imported at module scope, which is correct: dashboard/auth.py
51
+ # imports PyJWT LAZILY (inside functions, auth.py:64 and :622), so importing
52
+ # auth does NOT require PyJWT to be installed. An earlier version of this file
53
+ # deferred the import on the theory that it did, and in doing so called the
54
+ # async dependency by hand and threw away the un-awaited coroutine -- the scope
55
+ # check silently stopped running. require_scope returns an ASYNC callable that
56
+ # FastAPI must inject (it takes Security(get_current_token)), so it belongs in
57
+ # Depends() and nowhere else.
58
+ from . import auth
59
+
60
+ # Every route below carries the "read" scope, matching api_v2. These endpoints
61
+ # expose run detail, gate results, receipt verdicts and release state -- all of
62
+ # it operational evidence about a real workspace, and none of it public.
63
+ #
64
+ # This is not optional politeness: tests/dashboard/test_all_data_gets_scoped.py
65
+ # is a regression guard for the v7.x finding that 68 of 110 GET routes carried
66
+ # no dependency, so an unauthenticated caller could read runs, tasks, memory and
67
+ # findings whenever LOKI_ENTERPRISE_AUTH was on. It caught these four routes
68
+ # unguarded on their first run, which is precisely what it exists to do.
69
+ _READ = [Depends(auth.require_scope("read"))]
70
+
71
+
72
+ def _loki_dir() -> str:
73
+ """The workspace this dashboard is reporting on.
74
+
75
+ Resolved per-request rather than at import time: the dashboard is started
76
+ from the workspace directory in some deployments and given LOKI_DIR in
77
+ others, and an import-time snapshot would pin whichever was true when the
78
+ module first loaded.
79
+ """
80
+ return os.environ.get("LOKI_DIR") or os.path.join(os.getcwd(), ".loki")
81
+
82
+
83
+ def _repo_dir() -> str:
84
+ return os.environ.get("LOKI_REPO_DIR") or os.getcwd()
85
+
86
+
87
+ # Ceilings for the receipt walk on the HTTP path. Generous enough that a real
88
+ # workspace (this repo's own archive is on the order of 75 receipts) never hits
89
+ # them, low enough that a pathological tree cannot hold a worker open. Both are
90
+ # overridable so an operator with a genuinely large archive can raise them
91
+ # rather than silently receiving PARTIAL results forever.
92
+ def _int_env(name: str, default: int) -> int:
93
+ try:
94
+ v = int(os.environ.get(name, "") or default)
95
+ return v if v > 0 else default
96
+ except ValueError:
97
+ return default
98
+
99
+
100
+ _RECEIPT_SCAN_MAX_ENTRIES = _int_env("LOKI_RECEIPT_SCAN_MAX_ENTRIES", 50000)
101
+ _RECEIPT_SCAN_MAX_SECONDS = _int_env("LOKI_RECEIPT_SCAN_MAX_SECONDS", 10)
102
+
103
+
104
+ def _allowed_roots() -> list:
105
+ """The only trees a caller may ask this API to walk.
106
+
107
+ LOKI_OPERATOR_ROOTS (os.pathsep-separated) when set, else the workspace and
108
+ the repo. Resolved to real paths so the comparison in _resolve_workspace
109
+ happens after symlinks are followed, not before.
110
+ """
111
+ raw = os.environ.get("LOKI_OPERATOR_ROOTS")
112
+ candidates = raw.split(os.pathsep) if raw else [_loki_dir(), _repo_dir()]
113
+ roots = []
114
+ for c in candidates:
115
+ c = c.strip()
116
+ if not c:
117
+ continue
118
+ try:
119
+ roots.append(os.path.realpath(c))
120
+ except OSError:
121
+ continue
122
+ return roots
123
+
124
+
125
+ def _resolve_workspace(workspace: Optional[str]) -> str:
126
+ """Confine `workspace` to an allowed root, or refuse.
127
+
128
+ WHY THIS EXISTS. receipts_report walks its argument with an UNBOUNDED
129
+ rglob (api_evidence.py:346) and stats every entry. Passed a caller-supplied
130
+ path this is a filesystem-traversal primitive: `?workspace=/` walks the
131
+ whole disk, `../../..` climbs out of the workspace, and a symlink inside an
132
+ allowed root points anywhere at all. The auth dependency is not a
133
+ sufficient answer, because LOKI_ENTERPRISE_AUTH is off by default, so on a
134
+ default deployment this route is reachable unauthenticated.
135
+
136
+ The check is done on the REALPATH of both sides. Comparing the raw string
137
+ would be defeated by `..` segments and by a symlink whose name sits happily
138
+ inside an allowed root while its target does not.
139
+
140
+ Containment is tested by prefix on a path with a trailing separator:
141
+ plain startswith would let "/workspaces-evil" pass as inside
142
+ "/workspaces".
143
+ """
144
+ if workspace is None:
145
+ return _loki_dir()
146
+ try:
147
+ target = os.path.realpath(workspace)
148
+ except OSError as exc:
149
+ raise HTTPException(status_code=400,
150
+ detail="workspace is not a usable path: %s" % exc)
151
+ for root in _allowed_roots():
152
+ if target == root or target.startswith(root.rstrip(os.sep) + os.sep):
153
+ return target
154
+ # The refusal names the parameter but NOT the allowed roots: echoing them
155
+ # back turns a rejection into a filesystem-layout oracle.
156
+ raise HTTPException(
157
+ status_code=403,
158
+ detail="workspace is outside the configured operator roots")
159
+
160
+
161
+ def _fail(what: str, exc: Exception):
162
+ """A reader that raised is a 503, never an empty 200.
163
+
164
+ This is the honesty rule at the transport layer. Returning `{"runs": []}`
165
+ when the reader threw would be indistinguishable from a healthy empty
166
+ workspace, and the dashboard would render "no runs" over a broken disk.
167
+ """
168
+ logger.warning("operator reader %s failed: %s", what, exc)
169
+ raise HTTPException(
170
+ status_code=503,
171
+ detail="%s is unavailable: %s" % (what, exc),
172
+ )
173
+
174
+
175
+ @router.get("/runs/{run_id}", dependencies=_READ)
176
+ def operator_run_detail(run_id: str):
177
+ """One run plus its per-iteration records.
178
+
179
+ Per-iteration detail exists only for the CURRENT run -- the efficiency
180
+ records are wiped at run start (autonomy/run.sh). For any older run the
181
+ reader returns an empty `iterations` with a reason saying so, which is
182
+ correct and must not be mistaken for a run that did no work.
183
+ """
184
+ try:
185
+ from . import api_runs
186
+ return api_runs.get_run(_loki_dir(), run_id)
187
+ except Exception as exc:
188
+ _fail("run detail", exc)
189
+
190
+
191
+ @router.get("/tests", dependencies=_READ)
192
+ def operator_tests():
193
+ """Latest gate evidence: tests, static analysis, build, coverage.
194
+
195
+ Rows carry an explicit status rather than a bare boolean, because
196
+ "not run" and "ran and failed" are different operator situations and a
197
+ boolean collapses them.
198
+ """
199
+ try:
200
+ from . import api_tests
201
+ return api_tests.list_test_results(_loki_dir())
202
+ except Exception as exc:
203
+ _fail("test results", exc)
204
+
205
+
206
+ @router.get("/receipts", dependencies=_READ)
207
+ def operator_receipts(workspace: Optional[str] = Query(default=None)):
208
+ """Evidence Receipts discovered for this workspace, with a batch verdict.
209
+
210
+ Deliberately `receipts_report` and NOT `list_receipts`. The latter returns
211
+ a bare list, and a bare list cannot distinguish "this workspace has no
212
+ receipts" from "the walk failed" -- both arrive as []. The report carries
213
+ an explicit `verdict` (EMPTY for a tree with nothing in it, which is its
214
+ own state and not a pass), a `source` naming what was walked and what it
215
+ was re-verified against, and an `error` field.
216
+
217
+ The verdict is the WEAKEST state present, imported from the verifier
218
+ rather than restated here, so this route cannot drift into disagreeing
219
+ with `loki proof verify` about the same receipts.
220
+
221
+ The `workspace` parameter is confined to the configured operator roots by
222
+ _resolve_workspace before it reaches the walker. See that function for why
223
+ an auth dependency alone does not cover this.
224
+ """
225
+ root = _resolve_workspace(workspace)
226
+ try:
227
+ from . import api_evidence
228
+ # Bounded because this walk is reached from an HTTP request. Confining
229
+ # the ROOT (above) limits where it walks; it does not limit how much,
230
+ # and a deep allowed tree is still an unbounded rglob plus a stat per
231
+ # entry. A truncated walk is reported honestly: receipts_report holds
232
+ # the verdict down to UNVERIFIABLE and states that the audit is
233
+ # PARTIAL, rather than certifying the subset it happened to reach.
234
+ return api_evidence.receipts_report(
235
+ root, repo_dir=_repo_dir(),
236
+ max_entries=_RECEIPT_SCAN_MAX_ENTRIES,
237
+ max_seconds=_RECEIPT_SCAN_MAX_SECONDS)
238
+ except HTTPException:
239
+ # A 403/400 from the confinement check is the ANSWER, not a failure to
240
+ # read. Letting it fall into _fail would relabel a refused traversal as
241
+ # a 503 "receipts unavailable", which reads as a broken disk and hides
242
+ # that someone asked for a path they may not have.
243
+ raise
244
+ except Exception as exc:
245
+ _fail("receipts", exc)
246
+
247
+
248
+ @router.get("/releases", dependencies=_READ)
249
+ def operator_releases(limit: int = Query(default=20, ge=1, le=200)):
250
+ """Release history read from git tags, plus whether VERSION is ahead.
251
+
252
+ On a checkout with no tags -- which is what GitHub Actions produces --
253
+ the reader returns no rows, `newest_tag: None` and `version_is_ahead:
254
+ None`. That last field is deliberately None and not False: "I cannot
255
+ compare" is not "it is not ahead".
256
+ """
257
+ try:
258
+ from . import api_releases
259
+ return api_releases.list_releases(_repo_dir(), limit=limit)
260
+ except Exception as exc:
261
+ _fail("releases", exc)
262
+
263
+
264
+ @router.get("/phases", dependencies=_READ)
265
+ def operator_phases():
266
+ """Measured phase history for the current run, from real phase_change events.
267
+
268
+ Exists because the session-timeline component had no measured source and
269
+ SYNTHESIZED one: a fixed phase rotation with `Math.random()` durations,
270
+ rendered to an operator as history. The runtime does record the truth
271
+ (autonomy/run.sh:6562 emits phase_change to .loki/events.jsonl, NOT to
272
+ metrics/trust-events.jsonl); nothing exposed it.
273
+
274
+ Segment endpoints are event timestamps and nothing else. The opening
275
+ phase's start and the final phase's end were never emitted, so they read
276
+ None and are reported as such -- an unmeasured boundary must not be
277
+ back-computed from process uptime, which measures a different thing.
278
+ """
279
+ try:
280
+ from . import api_phases
281
+ return api_phases.phase_history(_loki_dir())
282
+ except Exception as exc:
283
+ _fail("phase history", exc)