loki-mode 9.18.4 → 9.20.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 (84) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/deadline.py +21 -1
  4. package/autonomy/lib/outcome_contract.py +128 -0
  5. package/autonomy/lib/proof-generator.py +45 -0
  6. package/autonomy/lib/proof-verify.py +53 -0
  7. package/autonomy/loki +980 -24
  8. package/autonomy/receipt_jwt.py +308 -0
  9. package/autonomy/run.sh +30 -0
  10. package/autonomy/trigger-server.py +186 -1
  11. package/dashboard/__init__.py +1 -1
  12. package/dashboard/server.py +83 -0
  13. package/dashboard/static/index.html +144 -0
  14. package/docs/COMPETITIVE-SCORECARD.md +76 -0
  15. package/docs/PROOF-PASSPORT.md +29 -0
  16. package/docs/SIGNED-RECEIPTS.md +111 -0
  17. package/docs/metrics.md +32 -0
  18. package/events/emit.sh +31 -0
  19. package/loki-ts/dist/loki.js +2 -2
  20. package/mcp/__init__.py +1 -1
  21. package/package.json +1 -1
  22. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  23. package/tools/proof-passport.py +175 -0
  24. package/web-app/dist/assets/{AdminPage-CcCJ0Sjt.js → AdminPage-DNGNuAge.js} +1 -1
  25. package/web-app/dist/assets/{Avatar-DK8kmayw.js → Avatar-CqAveJaT.js} +1 -1
  26. package/web-app/dist/assets/{Badge-4uAWnemi.js → Badge-BtMejA0Z.js} +1 -1
  27. package/web-app/dist/assets/{Button-BBMk33tk.js → Button-CikxmqSs.js} +1 -1
  28. package/web-app/dist/assets/{ComparePage-bt9rwvST.js → ComparePage-BB8DJBWR.js} +1 -1
  29. package/web-app/dist/assets/EvidenceReceiptPanel-6oquKEjd.js +1 -0
  30. package/web-app/dist/assets/{GitHubIssuesPanel-WDbH47UM.js → GitHubIssuesPanel-5XLYWzWH.js} +1 -1
  31. package/web-app/dist/assets/{GitHubPRsPanel-C2CiYtTx.js → GitHubPRsPanel-DUfmFYeu.js} +1 -1
  32. package/web-app/dist/assets/{HomePage-BQk-MUjn.js → HomePage-DZ4MmOdH.js} +5 -5
  33. package/web-app/dist/assets/{LoginPage-DMOZVGGL.js → LoginPage-VMzI6ROD.js} +1 -1
  34. package/web-app/dist/assets/{MagicPage-Bzp2Nt1z.js → MagicPage-CFbX5gWg.js} +1 -1
  35. package/web-app/dist/assets/{MetricsPage-C39JVdsw.js → MetricsPage-DbL6xmRQ.js} +2 -2
  36. package/web-app/dist/assets/{NotFoundPage-6vT_U9UL.js → NotFoundPage-BUrjXPAR.js} +1 -1
  37. package/web-app/dist/assets/{ProjectPage-BfFcZp-E.js → ProjectPage-iP6dJihg.js} +3 -3
  38. package/web-app/dist/assets/{ProjectsPage-CPMBf8Wt.js → ProjectsPage-BibN9bHl.js} +1 -1
  39. package/web-app/dist/assets/{SettingsPage-BnNN6ETl.js → SettingsPage-C0WJp1hW.js} +1 -1
  40. package/web-app/dist/assets/{ShowcasePage-WDrMf-cx.js → ShowcasePage-DUv0Njp8.js} +1 -1
  41. package/web-app/dist/assets/{SystemSettingsPage-DX4jb2e8.js → SystemSettingsPage-BIaCB0Vn.js} +1 -1
  42. package/web-app/dist/assets/{TeamsPage-BCfqcXzu.js → TeamsPage-BE0szv9r.js} +1 -1
  43. package/web-app/dist/assets/{TemplatesPage-CZvmimDj.js → TemplatesPage-BzkA9kat.js} +1 -1
  44. package/web-app/dist/assets/{TerminalOutput-BlRqFwWV.js → TerminalOutput-DBrdT_Fp.js} +1 -1
  45. package/web-app/dist/assets/{activity-CacZsUyr.js → activity-AbfFz38r.js} +1 -1
  46. package/web-app/dist/assets/{bell-DK2qtHnk.js → bell-DF6Z6y5U.js} +1 -1
  47. package/web-app/dist/assets/{bot-CkcUtHad.js → bot-DQ4np_3n.js} +1 -1
  48. package/web-app/dist/assets/{check-CbCPjX3M.js → check-RWuguCwE.js} +1 -1
  49. package/web-app/dist/assets/{chevron-left-5NUKWw3i.js → chevron-left-VpbQPOkC.js} +1 -1
  50. package/web-app/dist/assets/{circle-alert-S7uFoxC2.js → circle-alert-DwT82lJx.js} +1 -1
  51. package/web-app/dist/assets/{clock-CaQRrIrs.js → clock-BBlUa93G.js} +1 -1
  52. package/web-app/dist/assets/{cloud-DBAX6c0r.js → cloud-Dgbz3VCA.js} +1 -1
  53. package/web-app/dist/assets/{code-xml-De5-EXv3.js → code-xml-r6qqION5.js} +1 -1
  54. package/web-app/dist/assets/{copy-CUkT6k1v.js → copy-DMHj52B8.js} +1 -1
  55. package/web-app/dist/assets/{database-BAWf1Gwt.js → database-uJqHSHR1.js} +1 -1
  56. package/web-app/dist/assets/{dollar-sign-Ji8zk86R.js → dollar-sign-BZagljek.js} +1 -1
  57. package/web-app/dist/assets/{file-code-corner-ChtXoBwS.js → file-code-corner-DMopmhu0.js} +1 -1
  58. package/web-app/dist/assets/{file-plus-bFa37P76.js → file-plus-DU_hT_3w.js} +1 -1
  59. package/web-app/dist/assets/{folder-open-DhXpXscO.js → folder-open-BP5TtQAW.js} +1 -1
  60. package/web-app/dist/assets/{git-commit-horizontal-DVPeDQ3j.js → git-commit-horizontal-zFk_IobS.js} +1 -1
  61. package/web-app/dist/assets/{globe-BPZgPeeu.js → globe-mthCRtfp.js} +1 -1
  62. package/web-app/dist/assets/{hammer-jLCaujYH.js → hammer-BWaLBvSA.js} +1 -1
  63. package/web-app/dist/assets/{index-B-0iHBPO.js → index-BXA3UT6P.js} +3 -3
  64. package/web-app/dist/assets/{index-BfZSDej1.css → index-DgpUuqwR.css} +1 -1
  65. package/web-app/dist/assets/{layers-B1vsrsFW.js → layers-Q1LXV5fy.js} +1 -1
  66. package/web-app/dist/assets/{lightbulb-C-uLoq9Y.js → lightbulb-vRRNJ3dB.js} +1 -1
  67. package/web-app/dist/assets/{loader-circle-JTfD-ZuM.js → loader-circle-KgjeHEcV.js} +1 -1
  68. package/web-app/dist/assets/{lock-G9rxD4gZ.js → lock-BerKqno7.js} +1 -1
  69. package/web-app/dist/assets/{mail-BJ0PTN_V.js → mail-XAxTbprX.js} +1 -1
  70. package/web-app/dist/assets/{package-CXClfLOO.js → package-DLPKWAvW.js} +1 -1
  71. package/web-app/dist/assets/{plus-EoL5OCB7.js → plus-NMxxwHMn.js} +1 -1
  72. package/web-app/dist/assets/{refresh-cw-BjREUnVq.js → refresh-cw-Dt42kYtM.js} +1 -1
  73. package/web-app/dist/assets/{rotate-ccw-DahWX07H.js → rotate-ccw-BCR4Tp9u.js} +1 -1
  74. package/web-app/dist/assets/{save-Dek3gCn1.js → save-sR4MJd42.js} +1 -1
  75. package/web-app/dist/assets/{server-D6V1BAia.js → server-SXkchokx.js} +1 -1
  76. package/web-app/dist/assets/{shield-alert-BtTK5Sxb.js → shield-alert-Bvgp6_Dz.js} +1 -1
  77. package/web-app/dist/assets/{trash-2-BT5o_g0r.js → trash-2-D0IGudH7.js} +1 -1
  78. package/web-app/dist/assets/{trending-down-D4Jk7KF3.js → trending-down-BbFFBq-v.js} +1 -1
  79. package/web-app/dist/assets/{trending-up-EQFTzhEo.js → trending-up-DDrJKL5i.js} +1 -1
  80. package/web-app/dist/assets/{upload-JfI5lCSE.js → upload-C54rjN8K.js} +1 -1
  81. package/web-app/dist/assets/{usePolling-BnhPUuGd.js → usePolling-D7fw4YSM.js} +1 -1
  82. package/web-app/dist/assets/{user-DSUiUYtj.js → user-Cg7fbzdh.js} +1 -1
  83. package/web-app/dist/index.html +2 -2
  84. package/web-app/server.py +78 -0
@@ -0,0 +1,308 @@
1
+ """Per-job receipt attestation: a signed JWT plus a public JWKS.
2
+
3
+ WHY THIS EXISTS, given receipts are ALREADY gpg-signable.
4
+
5
+ `LOKI_PROOF_GPG_KEY` proves a receipt was produced by a holder of that gpg key,
6
+ and for a LOCAL build that is the whole story: the operator already has the
7
+ keyring. It does not survive the remote path. A submitter who ran
8
+ `loki start --remote https://loki.corp` never had local access to the cluster,
9
+ so "fetch our public key out of band and import it into gpg" is the step where
10
+ independent verification actually stops happening.
11
+
12
+ JWKS closes exactly that gap and nothing else: the verifier fetches
13
+ `/.well-known/jwks.json` over the same TLS connection it already trusts for the
14
+ API, and checks the signature with a stdlib-shaped algorithm. The receipt gains
15
+ per-job identity binding gpg never carried -- job id, submitter and issue time
16
+ as SIGNED claims, not prose in the body.
17
+
18
+ TWO DESIGN CONSTRAINTS, both decided by code that already exists rather than by
19
+ preference.
20
+
21
+ 1. THE RECEIVER SIGNS. NEVER THE WORKER. The worker executes model-directed
22
+ code and already holds ANTHROPIC_API_KEY and GITHUB_TOKEN
23
+ (helm/loki-mode/templates/worker-deployment.yaml). A signing key there would
24
+ let a build sign its own receipt, and a self-attested receipt attests to
25
+ nothing. The private key lives only in the receiver Deployment, matching the
26
+ rule stated at the top of templates/secret.yaml.
27
+
28
+ 2. `kid` AND A MULTI-KEY JWKS FROM DAY ONE. This is the half-built failure the
29
+ whole item was deferred over. With a single unlabeled key, the first rotation
30
+ makes every previously-issued receipt fail verification -- and a receipt that
31
+ fails verification is INDISTINGUISHABLE from a tampered one. That collapses
32
+ TAMPERED into UNCHECKED and destroys the four-verdict separation the trust
33
+ core is built on. So JWKS serves current AND retired keys, and every token
34
+ carries the `kid` that signed it.
35
+
36
+ Ed25519 (EdDSA), not RSA: fixed-size keys, no parameter choices to get wrong,
37
+ and `cryptography` is already a dependency. Signing is OPTIONAL and fail-open
38
+ in the sense that matters -- no key configured means no `attestation` field and
39
+ the receipt keeps its existing UNSIGNED verdict. It never means an unsigned
40
+ receipt is presented as signed.
41
+ """
42
+
43
+ import base64
44
+ import json
45
+ import logging
46
+ import os
47
+ from pathlib import Path
48
+
49
+ try:
50
+ from cryptography.hazmat.primitives import serialization
51
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
52
+ Ed25519PrivateKey,
53
+ Ed25519PublicKey,
54
+ )
55
+ _CRYPTO_AVAILABLE = True
56
+ except ImportError: # pragma: no cover - exercised only on a stripped install
57
+ _CRYPTO_AVAILABLE = False
58
+
59
+
60
+ def _b64url(data: bytes) -> str:
61
+ """base64url WITHOUT padding, per RFC 7515 section 2."""
62
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
63
+
64
+
65
+ def _b64url_decode(data: str) -> bytes:
66
+ """Inverse of _b64url, restoring the stripped padding."""
67
+ pad = 4 - len(data) % 4
68
+ if pad != 4:
69
+ data += "=" * pad
70
+ return base64.urlsafe_b64decode(data)
71
+
72
+
73
+ def compute_kid(public_key: "Ed25519PublicKey") -> str:
74
+ """Stable key id: the RFC 7638 JWK thumbprint of the public key.
75
+
76
+ Derived from the key itself rather than assigned by an operator, so the
77
+ same key always yields the same kid across restarts and redeploys. An
78
+ operator-chosen kid drifts between environments and silently breaks
79
+ verification of receipts issued before the drift.
80
+ """
81
+ raw = public_key.public_bytes(
82
+ encoding=serialization.Encoding.Raw,
83
+ format=serialization.PublicFormat.Raw,
84
+ )
85
+ # Members REQUIRED by RFC 7638 for OKP, lexicographic, no whitespace.
86
+ canonical = json.dumps(
87
+ {"crv": "Ed25519", "kty": "OKP", "x": _b64url(raw)},
88
+ separators=(",", ":"),
89
+ sort_keys=True,
90
+ ).encode("utf-8")
91
+ import hashlib
92
+
93
+ return _b64url(hashlib.sha256(canonical).digest())
94
+
95
+
96
+ def _load_private_key(pem_bytes: bytes):
97
+ """Load an unencrypted Ed25519 private key from PEM, or None.
98
+
99
+ An encrypted key is rejected rather than prompted for: the receiver starts
100
+ unattended in a pod and a passphrase prompt would hang startup rather than
101
+ fail it, which is the worse of the two outcomes.
102
+ """
103
+ try:
104
+ key = serialization.load_pem_private_key(pem_bytes, password=None)
105
+ except (ValueError, TypeError) as e:
106
+ logging.error("receipt signing: private key could not be loaded: %s", e)
107
+ return None
108
+ if not isinstance(key, Ed25519PrivateKey):
109
+ logging.error(
110
+ "receipt signing: key is %s, expected Ed25519 -- refusing to sign",
111
+ type(key).__name__,
112
+ )
113
+ return None
114
+ return key
115
+
116
+
117
+ def load_signing_key():
118
+ """Return (private_key, kid) from env or a mounted file, or (None, "").
119
+
120
+ Mirrors load_api_token's precedence deliberately: LOKI_RECEIPT_SIGNING_KEY
121
+ then LOKI_RECEIPT_SIGNING_KEY_FILE, the normal Kubernetes mounted-secret
122
+ path. Absence is NOT an error -- it is the default, and it means receipts
123
+ stay UNSIGNED exactly as they were before this module existed.
124
+ """
125
+ if not _CRYPTO_AVAILABLE:
126
+ return None, ""
127
+ pem = os.environ.get("LOKI_RECEIPT_SIGNING_KEY", "").strip()
128
+ if pem:
129
+ key = _load_private_key(pem.encode("utf-8"))
130
+ else:
131
+ key_file = os.environ.get("LOKI_RECEIPT_SIGNING_KEY_FILE", "").strip()
132
+ if not key_file:
133
+ return None, ""
134
+ try:
135
+ key = _load_private_key(Path(key_file).read_bytes())
136
+ except OSError as e:
137
+ logging.error(
138
+ "receipt signing: cannot read LOKI_RECEIPT_SIGNING_KEY_FILE %s: %s",
139
+ key_file, e,
140
+ )
141
+ return None, ""
142
+ if key is None:
143
+ return None, ""
144
+ return key, compute_kid(key.public_key())
145
+
146
+
147
+ def load_retired_public_keys():
148
+ """Public keys that must stay in JWKS so OLD receipts still verify.
149
+
150
+ THIS IS THE ROTATION STORY, and it is why the item was deferred until now.
151
+ LOKI_RECEIPT_RETIRED_PUBKEYS is a colon-separated list of PEM file paths.
152
+ After rotating, the operator moves the previous PUBLIC key here; receipts
153
+ it signed keep verifying, while nothing new is signed with it. Without this
154
+ a rotation would make every historical receipt read as unverifiable, which
155
+ a checker cannot distinguish from tampering.
156
+
157
+ A path that cannot be read is logged and skipped rather than fatal: one bad
158
+ path must not take down the whole endpoint and with it verification of
159
+ every OTHER key.
160
+ """
161
+ if not _CRYPTO_AVAILABLE:
162
+ return []
163
+ raw = os.environ.get("LOKI_RECEIPT_RETIRED_PUBKEYS", "").strip()
164
+ if not raw:
165
+ return []
166
+ out = []
167
+ for path in raw.split(":"):
168
+ path = path.strip()
169
+ if not path:
170
+ continue
171
+ try:
172
+ pub = serialization.load_pem_public_key(Path(path).read_bytes())
173
+ except (OSError, ValueError, TypeError) as e:
174
+ logging.error("receipt signing: skipping retired key %s: %s", path, e)
175
+ continue
176
+ if not isinstance(pub, Ed25519PublicKey):
177
+ logging.error("receipt signing: retired key %s is not Ed25519", path)
178
+ continue
179
+ out.append(pub)
180
+ return out
181
+
182
+
183
+ def public_jwk(public_key: "Ed25519PublicKey", kid: str = "") -> dict:
184
+ """One JWKS entry. `use`/`alg` are set so a verifier need not infer them."""
185
+ raw = public_key.public_bytes(
186
+ encoding=serialization.Encoding.Raw,
187
+ format=serialization.PublicFormat.Raw,
188
+ )
189
+ return {
190
+ "kty": "OKP",
191
+ "crv": "Ed25519",
192
+ "x": _b64url(raw),
193
+ "use": "sig",
194
+ "alg": "EdDSA",
195
+ "kid": kid or compute_kid(public_key),
196
+ }
197
+
198
+
199
+ def build_jwks(private_key=None, retired_public_keys=None) -> dict:
200
+ """The full public key set: the active key first, then retired ones.
201
+
202
+ Deduplicated by kid so listing a retired key that is still active does not
203
+ publish it twice -- a duplicate kid makes key selection ambiguous for a
204
+ verifier that stops at the first match.
205
+ """
206
+ keys, seen = [], set()
207
+ if private_key is not None:
208
+ jwk = public_jwk(private_key.public_key())
209
+ keys.append(jwk)
210
+ seen.add(jwk["kid"])
211
+ for pub in retired_public_keys or []:
212
+ jwk = public_jwk(pub)
213
+ if jwk["kid"] in seen:
214
+ continue
215
+ keys.append(jwk)
216
+ seen.add(jwk["kid"])
217
+ return {"keys": keys}
218
+
219
+
220
+ def sign_attestation(private_key, kid, *, job_id, run_id, receipt_hash,
221
+ submitter="", issued_at=None, issuer=""):
222
+ """Return a compact JWT binding a receipt hash to a job identity.
223
+
224
+ The signed claims are what gpg could never carry: WHICH job produced this
225
+ receipt, WHO submitted it, and WHEN. `receipt_hash` is the integrity hash
226
+ the receipt already computes over itself with `verification` stripped, so
227
+ the JWT attests to the same bytes a checker independently recomputes --
228
+ signing a different digest would let the two disagree while both look
229
+ valid.
230
+
231
+ Returns "" when signing is not configured. The caller must treat that as
232
+ UNSIGNED and must not present the receipt as attested.
233
+ """
234
+ if private_key is None or not kid:
235
+ return ""
236
+ if issued_at is None:
237
+ import time
238
+ issued_at = int(time.time())
239
+ header = {"alg": "EdDSA", "typ": "JWT", "kid": kid}
240
+ payload = {
241
+ "job_id": job_id,
242
+ "run_id": run_id,
243
+ "receipt_sha256": receipt_hash,
244
+ "iat": issued_at,
245
+ }
246
+ # Optional claims are omitted rather than emitted empty: a present-but-blank
247
+ # `sub` reads as "an anonymous submitter was recorded" instead of "no
248
+ # submitter identity was available".
249
+ if submitter:
250
+ payload["sub"] = submitter
251
+ if issuer:
252
+ payload["iss"] = issuer
253
+ signing_input = (
254
+ _b64url(json.dumps(header, separators=(",", ":"), sort_keys=True).encode())
255
+ + "."
256
+ + _b64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode())
257
+ )
258
+ try:
259
+ sig = private_key.sign(signing_input.encode("ascii"))
260
+ except Exception as e: # pragma: no cover - key validated at load
261
+ logging.error("receipt signing: sign failed: %s", e)
262
+ return ""
263
+ return signing_input + "." + _b64url(sig)
264
+
265
+
266
+ def verify_attestation(token: str, jwks: dict):
267
+ """Verify a compact JWT against a JWKS. Returns (ok, claims_or_reason).
268
+
269
+ Present so the CLI and tests check tokens the same way a third party would,
270
+ against the published key set only -- never against the private key, which
271
+ would pass even if JWKS published the wrong thing.
272
+
273
+ Selection is by `kid`. A token whose kid is absent from the set is REFUSED
274
+ rather than tried against every key: trying all of them would make a
275
+ rotated-away receipt indistinguishable from one signed by an unpublished
276
+ key.
277
+ """
278
+ if not _CRYPTO_AVAILABLE:
279
+ return False, "cryptography is not installed"
280
+ parts = token.split(".")
281
+ if len(parts) != 3:
282
+ return False, "malformed token"
283
+ try:
284
+ header = json.loads(_b64url_decode(parts[0]))
285
+ payload = json.loads(_b64url_decode(parts[1]))
286
+ sig = _b64url_decode(parts[2])
287
+ except (ValueError, json.JSONDecodeError):
288
+ return False, "malformed token"
289
+ if header.get("alg") != "EdDSA":
290
+ # Refusing an unexpected alg is what blocks the classic "alg: none" and
291
+ # algorithm-substitution attacks.
292
+ return False, "unexpected alg: %s" % header.get("alg")
293
+ kid = header.get("kid", "")
294
+ if not kid:
295
+ return False, "token carries no kid"
296
+ match = None
297
+ for jwk in jwks.get("keys", []):
298
+ if jwk.get("kid") == kid:
299
+ match = jwk
300
+ break
301
+ if match is None:
302
+ return False, "no published key with kid %s" % kid
303
+ try:
304
+ pub = Ed25519PublicKey.from_public_bytes(_b64url_decode(match["x"]))
305
+ pub.verify(sig, (parts[0] + "." + parts[1]).encode("ascii"))
306
+ except Exception:
307
+ return False, "signature does not verify"
308
+ return True, payload
package/autonomy/run.sh CHANGED
@@ -12393,6 +12393,36 @@ auto_generate_docs_if_needed() {
12393
12393
  # never pushed). Wrap it in a timeout; docs are non-gating, so on timeout we
12394
12394
  # warn and continue to the gate (which scores whatever docs exist).
12395
12395
  local _doc_to="${LOKI_DOCS_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
12396
+ # SCALE THE BUDGET TO WHAT THERE IS TO DOCUMENT. Measured on a graded
12397
+ # single-file build (hard-1-order-api): 2.8 min of iteration work, 13.4 min
12398
+ # wall clock, and 5.0 of those minutes were THIS step timing out at 300s
12399
+ # over one 20-line Python file -- 37% of the build spent generating an
12400
+ # eight-file architecture suite for a project that has one module.
12401
+ #
12402
+ # The simple-tier fast-path above did not catch it: detect_complexity reads
12403
+ # PRD PROSE (288 words, 3 sections) rather than build effort, so a
12404
+ # tersely-specified one-function task classifies "standard". Rather than
12405
+ # move those thresholds -- they also drive iteration caps and model tiers --
12406
+ # this bounds the WASTE directly, where the cost actually is.
12407
+ #
12408
+ # Only ever LOWERS the budget, and only when the operator did not set one.
12409
+ # A big project keeps the full 300s. Counting is capped so the find itself
12410
+ # cannot become the new cost.
12411
+ if [ -z "${LOKI_DOCS_TIMEOUT:-}" ]; then
12412
+ local _doc_src=0
12413
+ _doc_src=$(find "$project_dir" -type f \
12414
+ \( -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.tsx' \
12415
+ -o -name '*.go' -o -name '*.rs' -o -name '*.java' \) \
12416
+ -not -path '*/node_modules/*' -not -path '*/.loki/*' \
12417
+ -not -path '*/.git/*' -not -path '*/dist/*' 2>/dev/null | head -40 | wc -l | tr -d ' ')
12418
+ _doc_src="${_doc_src:-0}"
12419
+ # <=3 source files cannot need an architecture suite. 90s still allows a
12420
+ # README + USAGE pass, which is all the gate asks of a small project.
12421
+ if [ "$_doc_src" -le 3 ] && [ "$_doc_to" -gt 90 ]; then
12422
+ log_info "Auto-documentation: ${_doc_src} source file(s) -- capping generation at 90s (was ${_doc_to}s)"
12423
+ _doc_to=90
12424
+ fi
12425
+ fi
12396
12426
  local _doc_cmd=()
12397
12427
  if command -v gtimeout >/dev/null 2>&1; then
12398
12428
  _doc_cmd=(gtimeout "${_doc_to}s")
@@ -51,6 +51,35 @@ import time
51
51
  from datetime import datetime
52
52
  from pathlib import Path
53
53
 
54
+ # Receipt attestation. Imported by path because trigger-server.py runs as a
55
+ # script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
56
+ # the caller's directory. An ImportError is NOT fatal: signing is optional, and
57
+ # a server that refused to start without it would turn an optional feature into
58
+ # a hard dependency on `cryptography`.
59
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
60
+ try:
61
+ from receipt_jwt import (
62
+ build_jwks,
63
+ load_retired_public_keys,
64
+ load_signing_key,
65
+ sign_attestation,
66
+ )
67
+ _ATTESTATION_AVAILABLE = True
68
+ except ImportError: # pragma: no cover - only on a stripped install
69
+ _ATTESTATION_AVAILABLE = False
70
+
71
+ def build_jwks(private_key=None, retired_public_keys=None):
72
+ return {"keys": []}
73
+
74
+ def load_signing_key():
75
+ return None, ""
76
+
77
+ def load_retired_public_keys():
78
+ return []
79
+
80
+ def sign_attestation(*_a, **_k):
81
+ return ""
82
+
54
83
 
55
84
  # A GitHub repository full_name is "owner/repo". Both segments are restricted to
56
85
  # the characters GitHub itself allows (letters, digits, dot, underscore, dash).
@@ -757,6 +786,38 @@ class Dispatcher:
757
786
  return "", "unknown job id"
758
787
  return self._resolve_proof_id(entry)
759
788
 
789
+ def metrics(self):
790
+ """Queue depth and saturation, for autoscaling and for operators.
791
+
792
+ QUEUE DEPTH IS THE RIGHT SCALING SIGNAL HERE, AND CPU IS THE WRONG ONE.
793
+ A worker spends nearly all of a build waiting on a model API, so it is
794
+ idle by CPU measure while the backlog grows. An HPA on CPU would scale
795
+ DOWN a saturated cluster. Depth is the only metric that moves when work
796
+ is waiting.
797
+
798
+ `queue_saturation` is depth/maxsize, so a chart can target a fraction
799
+ without knowing the absolute size an operator configured.
800
+
801
+ Reads `_pending` under the same lock that writes it; `qsize()` is
802
+ approximate by design in CPython and is documented as such rather than
803
+ presented as exact.
804
+ """
805
+ with self._jobs_lock:
806
+ pending = self._pending
807
+ dispatched = self._dispatch_seq
808
+ depth = self.queue.qsize()
809
+ maxsize = self.queue.maxsize or 1
810
+ return {
811
+ "queue_depth": depth,
812
+ "queue_maxsize": maxsize,
813
+ "queue_saturation": round(depth / maxsize, 4),
814
+ "dispatched_total": dispatched,
815
+ "awaiting_receipt": pending,
816
+ "note": "queue_depth is approximate (queue.qsize semantics); scale on "
817
+ "queue_saturation, never on CPU -- a worker blocked on a model "
818
+ "call is idle by CPU measure while the backlog grows.",
819
+ }
820
+
760
821
  def submit(self, event_type, payload):
761
822
  """Enqueue an event. Returns True if accepted, False if the queue is full."""
762
823
  try:
@@ -811,6 +872,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
811
872
  # above: one authenticates GitHub, the other authenticates a human.
812
873
  api_token = ""
813
874
  dispatcher = None
875
+ # Ed25519 key that attests receipts, and the retired public keys that keep
876
+ # pre-rotation receipts verifiable. Both default to unconfigured, which
877
+ # means receipts stay UNSIGNED exactly as before this existed.
878
+ signing_key = None
879
+ signing_kid = ""
880
+ retired_pubkeys = ()
814
881
 
815
882
  # Cap the body we will read so a huge POST cannot exhaust memory.
816
883
  MAX_BODY_BYTES = 5 * 1024 * 1024
@@ -833,6 +900,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
833
900
  "secret_configured": bool(self.secret),
834
901
  "api_token_configured": bool(self.api_token),
835
902
  })
903
+ elif path == "/.well-known/jwks.json":
904
+ self._handle_jwks()
905
+ elif path == "/metrics":
906
+ self._handle_metrics()
836
907
  elif path.startswith("/jobs/") and path.endswith("/proof"):
837
908
  self._handle_job_proof(path[len("/jobs/"):-len("/proof")])
838
909
  elif path.startswith("/jobs/"):
@@ -885,6 +956,49 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
885
956
  return
886
957
  self._send_json(200, job)
887
958
 
959
+ def _handle_metrics(self):
960
+ """GET /metrics -- queue depth for autoscaling and for operators.
961
+
962
+ AUTHENTICATED, unlike /.well-known/jwks.json, and the difference is
963
+ deliberate. JWKS is public key material whose whole value is that
964
+ anyone can fetch it. Queue depth is operational posture: it reveals
965
+ load, capacity and whether a cluster is saturated. Publishing that
966
+ unauthenticated would hand an attacker a free saturation oracle.
967
+
968
+ In-cluster scrapers (an HPA adapter, Prometheus) present the same
969
+ bearer token as any other /jobs caller, so no new credential is
970
+ introduced for this.
971
+ """
972
+ if not self._check_api_auth():
973
+ return
974
+ if self.dispatcher is None:
975
+ self._send_json(503, {"error": "no dispatcher"})
976
+ return
977
+ self._send_json(200, self.dispatcher.metrics())
978
+
979
+ def _handle_jwks(self):
980
+ """GET /.well-known/jwks.json -- the public keys that sign receipts.
981
+
982
+ DELIBERATELY UNAUTHENTICATED, and that is the whole point. This is
983
+ public key material, and the value of an attested receipt is that a
984
+ third party can check it WITHOUT holding our API token. Requiring the
985
+ token here would mean only people we already trust could verify, which
986
+ is the same as not being verifiable at all.
987
+
988
+ Serves the active key plus any retired ones, so a receipt issued before
989
+ a rotation still verifies afterwards. Without that, rotating would make
990
+ every historical receipt read as unverifiable -- and a checker cannot
991
+ tell that apart from tampering.
992
+
993
+ Returns an empty key set (not a 404) when signing is unconfigured: the
994
+ endpoint exists and honestly reports that nothing is signed, which is
995
+ distinguishable from "this server has no such endpoint".
996
+ """
997
+ self._send_json(200, build_jwks(
998
+ private_key=self.signing_key,
999
+ retired_public_keys=self.retired_pubkeys,
1000
+ ))
1001
+
888
1002
  def _handle_job_proof(self, job_id):
889
1003
  """GET /jobs/<id>/proof -- that job's Evidence Receipt.
890
1004
 
@@ -914,7 +1028,54 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
914
1028
  "reason": reason or "the recorded receipt could not be read",
915
1029
  })
916
1030
  return
917
- self._send_json(200, proof)
1031
+ self._send_json(200, self._attest(proof, job_id, run_id))
1032
+
1033
+ def _attest(self, proof, job_id, run_id):
1034
+ """Attach a signed attestation to a receipt, if signing is configured.
1035
+
1036
+ The JWT is bound to `verification.hash` -- the digest the receipt ALREADY
1037
+ records over itself with `verification` stripped. Signing any other value
1038
+ would let the attestation and the checker's own recomputation disagree
1039
+ while both looked valid, which is worse than not signing at all.
1040
+
1041
+ The token is added under `verification.attestation`, which sits inside
1042
+ the subtree the hash excludes. Putting it anywhere else would change the
1043
+ bytes the hash covers and make every honest receipt read as tampered the
1044
+ moment it was signed.
1045
+
1046
+ Returns the receipt UNCHANGED when signing is unconfigured or the hash
1047
+ is missing. An unsigned receipt is a valid state with an existing
1048
+ verdict; a receipt claiming an attestation it does not have is not.
1049
+ """
1050
+ if self.signing_key is None:
1051
+ return proof
1052
+ verification = proof.get("verification")
1053
+ if not isinstance(verification, dict):
1054
+ return proof
1055
+ receipt_hash = verification.get("hash") or ""
1056
+ if not receipt_hash:
1057
+ # No self-recorded digest means there is nothing to bind to. Signing
1058
+ # the whole body instead would attest to bytes the checker never
1059
+ # recomputes, so the honest move is to leave it unsigned.
1060
+ logging.warning(
1061
+ "receipt attestation: run %s has no verification.hash; left UNSIGNED",
1062
+ run_id,
1063
+ )
1064
+ return proof
1065
+ token = sign_attestation(
1066
+ self.signing_key, self.signing_kid,
1067
+ job_id=job_id, run_id=run_id, receipt_hash=receipt_hash,
1068
+ )
1069
+ if not token:
1070
+ return proof
1071
+ # Copied rather than mutated: read_proof returns a fresh parse today, but
1072
+ # a future cache would make in-place mutation compound tokens across
1073
+ # requests.
1074
+ out = dict(proof)
1075
+ out["verification"] = dict(verification)
1076
+ out["verification"]["attestation"] = token
1077
+ out["verification"]["attestation_kid"] = self.signing_kid
1078
+ return out
918
1079
 
919
1080
  def _read_body(self):
920
1081
  """Read and return the request body, or None if it was refused.
@@ -1199,6 +1360,11 @@ def main():
1199
1360
  WebhookHandler.api_token = api_token
1200
1361
  WebhookHandler.dispatcher = dispatcher
1201
1362
 
1363
+ signing_key, signing_kid = load_signing_key()
1364
+ WebhookHandler.signing_key = signing_key
1365
+ WebhookHandler.signing_kid = signing_kid
1366
+ WebhookHandler.retired_pubkeys = tuple(load_retired_public_keys())
1367
+
1202
1368
  server = ThreadingWebhookServer(("", port), WebhookHandler)
1203
1369
  write_pid_file()
1204
1370
 
@@ -1207,6 +1373,25 @@ def main():
1207
1373
  logging.info("Webhook endpoint: POST http://localhost:%d/webhook", port)
1208
1374
  logging.info("Health check: GET http://localhost:%d/health", port)
1209
1375
  logging.info("Submit endpoint: POST http://localhost:%d/jobs", port)
1376
+ if signing_key is not None:
1377
+ logging.info(
1378
+ "Receipt attestation: ACTIVE (kid %s), public keys at "
1379
+ "GET http://localhost:%d/.well-known/jwks.json%s",
1380
+ signing_kid, port,
1381
+ " (+%d retired)" % len(WebhookHandler.retired_pubkeys)
1382
+ if WebhookHandler.retired_pubkeys else "",
1383
+ )
1384
+ elif not _ATTESTATION_AVAILABLE:
1385
+ logging.info(
1386
+ "Receipt attestation: unavailable (cryptography not installed); "
1387
+ "receipts remain UNSIGNED"
1388
+ )
1389
+ else:
1390
+ logging.info(
1391
+ "Receipt attestation: not configured; receipts remain UNSIGNED. "
1392
+ "Set LOKI_RECEIPT_SIGNING_KEY_FILE to let submitters verify a "
1393
+ "receipt without trusting this server."
1394
+ )
1210
1395
  logging.info("Workers: %d, queue size: %d", workers, queue_size)
1211
1396
  if not api_token:
1212
1397
  logging.warning(
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.18.4"
10
+ __version__ = "9.20.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: