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
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v9.
|
|
6
|
+
# Loki Mode v9.20.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
470
470
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
|
-
**v9.
|
|
473
|
+
**v9.20.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
9.
|
|
1
|
+
9.20.0
|
package/autonomy/lib/deadline.py
CHANGED
|
@@ -281,7 +281,16 @@ def _process_has_lineage_token(pid: int, token: str) -> bool:
|
|
|
281
281
|
if sys.platform.startswith("linux"):
|
|
282
282
|
try:
|
|
283
283
|
with open(f"/proc/{pid}/environ", "rb") as handle:
|
|
284
|
-
|
|
284
|
+
payload = handle.read()
|
|
285
|
+
if not payload:
|
|
286
|
+
# A zero-length read raises NOTHING. The kernel empties
|
|
287
|
+
# /proc/<pid>/environ as the process is reaped, so a fast
|
|
288
|
+
# runner yields b"" -- which splits to [b""], does not contain
|
|
289
|
+
# the needle, and returns "no marker" for a process that
|
|
290
|
+
# carried one. Indistinguishable from the FileNotFoundError
|
|
291
|
+
# case below and must be answered the same way.
|
|
292
|
+
raise _LineageUnknown(pid)
|
|
293
|
+
return needle in payload.split(b"\0")
|
|
285
294
|
except FileNotFoundError:
|
|
286
295
|
# The process is already gone. /proc/<pid>/environ disappears the
|
|
287
296
|
# moment a child is reaped, so a short-lived runner that exits
|
|
@@ -295,7 +304,18 @@ def _process_has_lineage_token(pid: int, token: str) -> bool:
|
|
|
295
304
|
# macOS never hit this: it has the pipe-handle fallback, so the
|
|
296
305
|
# bug was Linux-only and invisible on a developer Mac.
|
|
297
306
|
raise _LineageUnknown(pid) from None
|
|
307
|
+
except ProcessLookupError:
|
|
308
|
+
# ESRCH: the pid was reaped between opening and reading. Same fact
|
|
309
|
+
# as ENOENT above -- the process is gone, so its marker is UNKNOWN,
|
|
310
|
+
# not absent. Listed BEFORE the OSError clause because it is a
|
|
311
|
+
# subclass and would otherwise be swallowed into "no marker",
|
|
312
|
+
# which fails the lineage guard and reports 127 for a process that
|
|
313
|
+
# launched and completed.
|
|
314
|
+
raise _LineageUnknown(pid) from None
|
|
298
315
|
except OSError:
|
|
316
|
+
# Anything else (EACCES on a hardened /proc, EIO) is a genuine
|
|
317
|
+
# read failure rather than evidence of a completed child, so it
|
|
318
|
+
# stays fail-closed: no marker.
|
|
299
319
|
return False
|
|
300
320
|
return False
|
|
301
321
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure-stdlib validation and deterministic identity for Outcome Contract v0.1."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
SCHEMA = "autonomi-outcome-contract/v0.1"
|
|
7
|
+
KINDS = {"human", "agent", "service", "organization"}
|
|
8
|
+
TOP_KEYS = {"schema", "id", "intent", "acceptance", "executor", "verifier",
|
|
9
|
+
"constraints", "evidence", "risk", "budget", "deployment",
|
|
10
|
+
"rollback", "dispute"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContractValidationError(ValueError):
|
|
14
|
+
def __init__(self, path, message):
|
|
15
|
+
self.path = path
|
|
16
|
+
self.message = message
|
|
17
|
+
super().__init__("%s: %s" % (path, message))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail(path, message):
|
|
21
|
+
raise ContractValidationError(path, message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _text(value, path):
|
|
25
|
+
if not isinstance(value, str) or not value.strip():
|
|
26
|
+
_fail(path, "must be a non-empty string")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _identity(value, path):
|
|
30
|
+
if not isinstance(value, dict):
|
|
31
|
+
_fail(path, "must be an object")
|
|
32
|
+
extra = sorted(set(value) - {"id", "kind"})
|
|
33
|
+
if extra:
|
|
34
|
+
_fail(path + "." + extra[0], "unknown property")
|
|
35
|
+
for key in ("id", "kind"):
|
|
36
|
+
if key not in value:
|
|
37
|
+
_fail(path + "." + key, "is required")
|
|
38
|
+
_text(value["id"], path + ".id")
|
|
39
|
+
if value["kind"] not in KINDS:
|
|
40
|
+
_fail(path + ".kind", "must be one of: %s" % ", ".join(sorted(KINDS)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _object(value, path, required, allowed):
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
_fail(path, "must be an object")
|
|
46
|
+
extra = sorted(set(value) - set(allowed))
|
|
47
|
+
if extra:
|
|
48
|
+
_fail(path + "." + extra[0], "unknown property")
|
|
49
|
+
for key in required:
|
|
50
|
+
if key not in value:
|
|
51
|
+
_fail(path + "." + key, "is required")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate(contract):
|
|
55
|
+
if not isinstance(contract, dict):
|
|
56
|
+
_fail("$", "must be an object")
|
|
57
|
+
extra = sorted(set(contract) - TOP_KEYS)
|
|
58
|
+
if extra:
|
|
59
|
+
_fail("$." + extra[0], "unknown property")
|
|
60
|
+
for key in ("schema", "id", "intent", "acceptance", "executor", "verifier"):
|
|
61
|
+
if key not in contract:
|
|
62
|
+
_fail("$." + key, "is required")
|
|
63
|
+
if contract["schema"] != SCHEMA:
|
|
64
|
+
_fail("$.schema", "must equal %s" % SCHEMA)
|
|
65
|
+
_text(contract["id"], "$.id")
|
|
66
|
+
_text(contract["intent"], "$.intent")
|
|
67
|
+
acceptance = contract["acceptance"]
|
|
68
|
+
if not isinstance(acceptance, list) or not acceptance:
|
|
69
|
+
_fail("$.acceptance", "must be a non-empty array")
|
|
70
|
+
for index, item in enumerate(acceptance):
|
|
71
|
+
_text(item, "$.acceptance[%d]" % index)
|
|
72
|
+
_identity(contract["executor"], "$.executor")
|
|
73
|
+
_identity(contract["verifier"], "$.verifier")
|
|
74
|
+
if contract["executor"]["id"] == contract["verifier"]["id"]:
|
|
75
|
+
_fail("$.verifier.id", "must differ from executor.id")
|
|
76
|
+
if "constraints" in contract and not isinstance(contract["constraints"], dict):
|
|
77
|
+
_fail("$.constraints", "must be an object")
|
|
78
|
+
if "risk" in contract:
|
|
79
|
+
value = contract["risk"]
|
|
80
|
+
_object(value, "$.risk", ("level",), ("level", "notes"))
|
|
81
|
+
if value["level"] not in {"low", "medium", "high", "critical"}:
|
|
82
|
+
_fail("$.risk.level", "must be one of: low, medium, high, critical")
|
|
83
|
+
if "notes" in value:
|
|
84
|
+
_text(value["notes"], "$.risk.notes")
|
|
85
|
+
if "budget" in contract:
|
|
86
|
+
value = contract["budget"]
|
|
87
|
+
_object(value, "$.budget", ("max_amount", "currency"),
|
|
88
|
+
("max_amount", "currency"))
|
|
89
|
+
if isinstance(value["max_amount"], bool) or not isinstance(
|
|
90
|
+
value["max_amount"], (int, float)) or value["max_amount"] < 0:
|
|
91
|
+
_fail("$.budget.max_amount", "must be a non-negative number")
|
|
92
|
+
_text(value["currency"], "$.budget.currency")
|
|
93
|
+
if "deployment" in contract:
|
|
94
|
+
value = contract["deployment"]
|
|
95
|
+
_object(value, "$.deployment", ("environment", "approval_required"),
|
|
96
|
+
("environment", "approval_required"))
|
|
97
|
+
_text(value["environment"], "$.deployment.environment")
|
|
98
|
+
if not isinstance(value["approval_required"], bool):
|
|
99
|
+
_fail("$.deployment.approval_required", "must be a boolean")
|
|
100
|
+
if "rollback" in contract:
|
|
101
|
+
value = contract["rollback"]
|
|
102
|
+
_object(value, "$.rollback", ("required", "strategy"),
|
|
103
|
+
("required", "strategy"))
|
|
104
|
+
if not isinstance(value["required"], bool):
|
|
105
|
+
_fail("$.rollback.required", "must be a boolean")
|
|
106
|
+
_text(value["strategy"], "$.rollback.strategy")
|
|
107
|
+
if "dispute" in contract:
|
|
108
|
+
value = contract["dispute"]
|
|
109
|
+
_object(value, "$.dispute", ("process",), ("process", "arbiter"))
|
|
110
|
+
_text(value["process"], "$.dispute.process")
|
|
111
|
+
if "arbiter" in value:
|
|
112
|
+
_identity(value["arbiter"], "$.dispute.arbiter")
|
|
113
|
+
if "evidence" in contract:
|
|
114
|
+
if not isinstance(contract["evidence"], list):
|
|
115
|
+
_fail("$.evidence", "must be an array")
|
|
116
|
+
for index, item in enumerate(contract["evidence"]):
|
|
117
|
+
_text(item, "$.evidence[%d]" % index)
|
|
118
|
+
return contract
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def canonicalize(contract):
|
|
122
|
+
validate(contract)
|
|
123
|
+
return json.dumps(contract, sort_keys=True, separators=(",", ":"),
|
|
124
|
+
ensure_ascii=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def digest(contract):
|
|
128
|
+
return hashlib.sha256(canonicalize(contract).encode("utf-8")).hexdigest()
|
|
@@ -1867,6 +1867,51 @@ def generate(args):
|
|
|
1867
1867
|
if sig:
|
|
1868
1868
|
verification["gpg_signature"] = sig
|
|
1869
1869
|
|
|
1870
|
+
# Optional Ed25519 attestation over the SAME digest, for the case gpg
|
|
1871
|
+
# cannot serve: a receipt checked by someone who does not hold the signing
|
|
1872
|
+
# key. gpg proves provenance only to a verifier who already imported the
|
|
1873
|
+
# public key, which a customer, auditor or CI system handed a proof.json
|
|
1874
|
+
# generally has not. An attestation is checkable against a published JWKS
|
|
1875
|
+
# (or a jwks.json file, offline) with no key exchange at all.
|
|
1876
|
+
#
|
|
1877
|
+
# Binds the DIGEST, not the body, so it attests to exactly the bytes a
|
|
1878
|
+
# verifier independently recomputes. Written INSIDE `verification`, which
|
|
1879
|
+
# the hash excludes -- anywhere else would change the hashed bytes and make
|
|
1880
|
+
# every honest receipt read as tampered the moment it was signed.
|
|
1881
|
+
#
|
|
1882
|
+
# Same default-OFF discipline as gpg above: absent the key, no field, and
|
|
1883
|
+
# the bytes stay identical to an unattested proof. Best-effort for the same
|
|
1884
|
+
# reason -- a signing failure must not cost the user their receipt.
|
|
1885
|
+
# This env check is an EARLY EXIT, not the control that enforces default-off.
|
|
1886
|
+
# Mutation testing showed that removing it changes nothing observable:
|
|
1887
|
+
# load_signing_key() returns (None, "") when neither variable is set, so the
|
|
1888
|
+
# block below cannot attest anyway. It is kept because it skips an import
|
|
1889
|
+
# and a try/except on the overwhelmingly common unconfigured path -- stated
|
|
1890
|
+
# here rather than left to imply a guarantee it does not provide.
|
|
1891
|
+
att_key = (os.environ.get("LOKI_RECEIPT_SIGNING_KEY_FILE", "").strip()
|
|
1892
|
+
or os.environ.get("LOKI_RECEIPT_SIGNING_KEY", "").strip())
|
|
1893
|
+
if att_key:
|
|
1894
|
+
try:
|
|
1895
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
|
1896
|
+
os.path.abspath(__file__))))
|
|
1897
|
+
from receipt_jwt import load_signing_key, sign_attestation
|
|
1898
|
+
_priv, _kid = load_signing_key()
|
|
1899
|
+
if _priv is not None:
|
|
1900
|
+
_tok = sign_attestation(
|
|
1901
|
+
_priv, _kid,
|
|
1902
|
+
job_id=str(redacted.get("run_id") or ""),
|
|
1903
|
+
run_id=str(redacted.get("run_id") or ""),
|
|
1904
|
+
receipt_hash=digest,
|
|
1905
|
+
)
|
|
1906
|
+
if _tok:
|
|
1907
|
+
verification["attestation"] = _tok
|
|
1908
|
+
verification["attestation_kid"] = _kid
|
|
1909
|
+
except Exception:
|
|
1910
|
+
# Swallowed deliberately, matching the gpg path: an unattested
|
|
1911
|
+
# receipt is a valid state with an existing verdict. Losing the
|
|
1912
|
+
# receipt entirely because signing failed would be strictly worse.
|
|
1913
|
+
pass
|
|
1914
|
+
|
|
1870
1915
|
redacted["verification"] = verification
|
|
1871
1916
|
|
|
1872
1917
|
# Determine output dir.
|
|
@@ -76,6 +76,46 @@ if _HERE not in sys.path:
|
|
|
76
76
|
from tree_digest import compute_tree_digest
|
|
77
77
|
from workspace_diff import collect_workspace_diff
|
|
78
78
|
|
|
79
|
+
# Receipt schema major this verifier understands. proof-generator.py writes
|
|
80
|
+
# schema_version ("1.1" today) and NOTHING read it -- a version stamped on
|
|
81
|
+
# every receipt and enforced nowhere, so a future incompatible receipt would
|
|
82
|
+
# have been verified against rules written for a different shape and reported
|
|
83
|
+
# as a clean pass.
|
|
84
|
+
#
|
|
85
|
+
# FAIL CLOSED on absent or unknown: a receipt we cannot place is not a receipt
|
|
86
|
+
# we can verify, and "cannot tell" must never render as "verified".
|
|
87
|
+
#
|
|
88
|
+
# ACCEPT EVERY MINOR of the supported major -- that is the legacy compatibility
|
|
89
|
+
# guarantee, and it is what keeps this from being a breaking change. Minor bumps
|
|
90
|
+
# are additive by convention, so 1.0 and today's 1.1 both verify; only a MAJOR
|
|
91
|
+
# bump (2.x) means the shape changed enough that these rules no longer apply.
|
|
92
|
+
SUPPORTED_SCHEMA_MAJOR = 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_schema_version(proof):
|
|
96
|
+
"""Return None when the receipt's schema is supported, else a reason string.
|
|
97
|
+
|
|
98
|
+
Returns a reason rather than raising so the caller folds it into the normal
|
|
99
|
+
reason/reasons/ok contract, which every consumer (loki proof verify, the
|
|
100
|
+
remote receipt path, the dashboard) already handles.
|
|
101
|
+
"""
|
|
102
|
+
raw = proof.get("schema_version")
|
|
103
|
+
if raw is None:
|
|
104
|
+
return ("receipt has no schema_version, so the rules to verify it "
|
|
105
|
+
"cannot be established (expected major %d)" % SUPPORTED_SCHEMA_MAJOR)
|
|
106
|
+
text = str(raw).strip()
|
|
107
|
+
major_text = text.split(".", 1)[0]
|
|
108
|
+
try:
|
|
109
|
+
major = int(major_text)
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return ("receipt schema_version %r is not a parseable version, so the "
|
|
112
|
+
"rules to verify it cannot be established" % raw)
|
|
113
|
+
if major != SUPPORTED_SCHEMA_MAJOR:
|
|
114
|
+
return ("receipt schema_version %s is major %d; this verifier "
|
|
115
|
+
"understands major %d only, so its checks may not apply"
|
|
116
|
+
% (text, major, SUPPORTED_SCHEMA_MAJOR))
|
|
117
|
+
return None
|
|
118
|
+
|
|
79
119
|
|
|
80
120
|
# ---------------------------------------------------------------------------
|
|
81
121
|
# canonicalization (MUST match proof-generator._canonical exactly)
|
|
@@ -863,11 +903,24 @@ def verify(proof_path, repo_dir="."):
|
|
|
863
903
|
"working tree changed after the receipt was generated" % (
|
|
864
904
|
recorded_tree, current_tree))
|
|
865
905
|
|
|
906
|
+
# ----- schema version --------------------------------------------------
|
|
907
|
+
# Folded into the verdict rather than raised, so every existing caller
|
|
908
|
+
# inherits it through reason/reasons/ok with no signature change. ANDed in
|
|
909
|
+
# below: an unsupported schema cannot be rescued by a passing hash, because
|
|
910
|
+
# the hash only proves the bytes are unedited -- not that these rules are
|
|
911
|
+
# the right ones to judge them by.
|
|
912
|
+
schema_reason = check_schema_version(proof)
|
|
913
|
+
result["schema_version"] = proof.get("schema_version")
|
|
914
|
+
result["schema_supported"] = schema_reason is None
|
|
915
|
+
if schema_reason:
|
|
916
|
+
result["reasons"].append(schema_reason)
|
|
917
|
+
|
|
866
918
|
# ----- overall verdict -------------------------------------------------
|
|
867
919
|
result["ok"] = bool(
|
|
868
920
|
integrity["ok"]
|
|
869
921
|
and result["diff_drift"] is False
|
|
870
922
|
and (not recorded_tree or result["tree_drift"] is False)
|
|
923
|
+
and schema_reason is None
|
|
871
924
|
)
|
|
872
925
|
if result["ok"]:
|
|
873
926
|
result["reason"] = ""
|