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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/deadline.py +21 -1
- package/autonomy/lib/outcome_contract.py +128 -0
- package/autonomy/lib/proof-generator.py +45 -0
- package/autonomy/lib/proof-verify.py +53 -0
- package/autonomy/loki +980 -24
- package/autonomy/receipt_jwt.py +308 -0
- package/autonomy/run.sh +30 -0
- package/autonomy/trigger-server.py +186 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +83 -0
- package/dashboard/static/index.html +144 -0
- package/docs/COMPETITIVE-SCORECARD.md +76 -0
- package/docs/PROOF-PASSPORT.md +29 -0
- package/docs/SIGNED-RECEIPTS.md +111 -0
- package/docs/metrics.md +32 -0
- package/events/emit.sh +31 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/proof-passport.py +175 -0
- package/web-app/dist/assets/{AdminPage-CcCJ0Sjt.js → AdminPage-DNGNuAge.js} +1 -1
- package/web-app/dist/assets/{Avatar-DK8kmayw.js → Avatar-CqAveJaT.js} +1 -1
- package/web-app/dist/assets/{Badge-4uAWnemi.js → Badge-BtMejA0Z.js} +1 -1
- package/web-app/dist/assets/{Button-BBMk33tk.js → Button-CikxmqSs.js} +1 -1
- package/web-app/dist/assets/{ComparePage-bt9rwvST.js → ComparePage-BB8DJBWR.js} +1 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-6oquKEjd.js +1 -0
- package/web-app/dist/assets/{GitHubIssuesPanel-WDbH47UM.js → GitHubIssuesPanel-5XLYWzWH.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C2CiYtTx.js → GitHubPRsPanel-DUfmFYeu.js} +1 -1
- package/web-app/dist/assets/{HomePage-BQk-MUjn.js → HomePage-DZ4MmOdH.js} +5 -5
- package/web-app/dist/assets/{LoginPage-DMOZVGGL.js → LoginPage-VMzI6ROD.js} +1 -1
- package/web-app/dist/assets/{MagicPage-Bzp2Nt1z.js → MagicPage-CFbX5gWg.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-C39JVdsw.js → MetricsPage-DbL6xmRQ.js} +2 -2
- package/web-app/dist/assets/{NotFoundPage-6vT_U9UL.js → NotFoundPage-BUrjXPAR.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BfFcZp-E.js → ProjectPage-iP6dJihg.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CPMBf8Wt.js → ProjectsPage-BibN9bHl.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BnNN6ETl.js → SettingsPage-C0WJp1hW.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-WDrMf-cx.js → ShowcasePage-DUv0Njp8.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DX4jb2e8.js → SystemSettingsPage-BIaCB0Vn.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BCfqcXzu.js → TeamsPage-BE0szv9r.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CZvmimDj.js → TemplatesPage-BzkA9kat.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BlRqFwWV.js → TerminalOutput-DBrdT_Fp.js} +1 -1
- package/web-app/dist/assets/{activity-CacZsUyr.js → activity-AbfFz38r.js} +1 -1
- package/web-app/dist/assets/{bell-DK2qtHnk.js → bell-DF6Z6y5U.js} +1 -1
- package/web-app/dist/assets/{bot-CkcUtHad.js → bot-DQ4np_3n.js} +1 -1
- package/web-app/dist/assets/{check-CbCPjX3M.js → check-RWuguCwE.js} +1 -1
- package/web-app/dist/assets/{chevron-left-5NUKWw3i.js → chevron-left-VpbQPOkC.js} +1 -1
- package/web-app/dist/assets/{circle-alert-S7uFoxC2.js → circle-alert-DwT82lJx.js} +1 -1
- package/web-app/dist/assets/{clock-CaQRrIrs.js → clock-BBlUa93G.js} +1 -1
- package/web-app/dist/assets/{cloud-DBAX6c0r.js → cloud-Dgbz3VCA.js} +1 -1
- package/web-app/dist/assets/{code-xml-De5-EXv3.js → code-xml-r6qqION5.js} +1 -1
- package/web-app/dist/assets/{copy-CUkT6k1v.js → copy-DMHj52B8.js} +1 -1
- package/web-app/dist/assets/{database-BAWf1Gwt.js → database-uJqHSHR1.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Ji8zk86R.js → dollar-sign-BZagljek.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-ChtXoBwS.js → file-code-corner-DMopmhu0.js} +1 -1
- package/web-app/dist/assets/{file-plus-bFa37P76.js → file-plus-DU_hT_3w.js} +1 -1
- package/web-app/dist/assets/{folder-open-DhXpXscO.js → folder-open-BP5TtQAW.js} +1 -1
- package/web-app/dist/assets/{git-commit-horizontal-DVPeDQ3j.js → git-commit-horizontal-zFk_IobS.js} +1 -1
- package/web-app/dist/assets/{globe-BPZgPeeu.js → globe-mthCRtfp.js} +1 -1
- package/web-app/dist/assets/{hammer-jLCaujYH.js → hammer-BWaLBvSA.js} +1 -1
- package/web-app/dist/assets/{index-B-0iHBPO.js → index-BXA3UT6P.js} +3 -3
- package/web-app/dist/assets/{index-BfZSDej1.css → index-DgpUuqwR.css} +1 -1
- package/web-app/dist/assets/{layers-B1vsrsFW.js → layers-Q1LXV5fy.js} +1 -1
- package/web-app/dist/assets/{lightbulb-C-uLoq9Y.js → lightbulb-vRRNJ3dB.js} +1 -1
- package/web-app/dist/assets/{loader-circle-JTfD-ZuM.js → loader-circle-KgjeHEcV.js} +1 -1
- package/web-app/dist/assets/{lock-G9rxD4gZ.js → lock-BerKqno7.js} +1 -1
- package/web-app/dist/assets/{mail-BJ0PTN_V.js → mail-XAxTbprX.js} +1 -1
- package/web-app/dist/assets/{package-CXClfLOO.js → package-DLPKWAvW.js} +1 -1
- package/web-app/dist/assets/{plus-EoL5OCB7.js → plus-NMxxwHMn.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BjREUnVq.js → refresh-cw-Dt42kYtM.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DahWX07H.js → rotate-ccw-BCR4Tp9u.js} +1 -1
- package/web-app/dist/assets/{save-Dek3gCn1.js → save-sR4MJd42.js} +1 -1
- package/web-app/dist/assets/{server-D6V1BAia.js → server-SXkchokx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BtTK5Sxb.js → shield-alert-Bvgp6_Dz.js} +1 -1
- package/web-app/dist/assets/{trash-2-BT5o_g0r.js → trash-2-D0IGudH7.js} +1 -1
- package/web-app/dist/assets/{trending-down-D4Jk7KF3.js → trending-down-BbFFBq-v.js} +1 -1
- package/web-app/dist/assets/{trending-up-EQFTzhEo.js → trending-up-DDrJKL5i.js} +1 -1
- package/web-app/dist/assets/{upload-JfI5lCSE.js → upload-C54rjN8K.js} +1 -1
- package/web-app/dist/assets/{usePolling-BnhPUuGd.js → usePolling-D7fw4YSM.js} +1 -1
- package/web-app/dist/assets/{user-DSUiUYtj.js → user-Cg7fbzdh.js} +1 -1
- package/web-app/dist/index.html +2 -2
- 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(
|