loki-mode 8.89.0 → 8.90.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/dashboard/__init__.py +1 -1
- 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/receipt-attest.py +350 -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 v8.
|
|
6
|
+
# Loki Mode v8.90.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
469
469
|
|
|
470
470
|
---
|
|
471
471
|
|
|
472
|
-
**v8.
|
|
472
|
+
**v8.90.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
8.
|
|
1
|
+
8.90.0
|
package/dashboard/__init__.py
CHANGED
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.
|
|
2
|
+
var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.90.0";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Xf(Qf(import.meta.url)),Q=X$(X);h5=e_(Zf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var bO={};l0(bO,{runOrThrow:()=>jf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Tf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>yO});async function NQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function jf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Mf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Mf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Tf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return wf?"":Z}var wf,L0,F8,p0,zV0,a0,W8,Q9,v;var S6=p(()=>{wf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),zV0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),W8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as bf}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(bf(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>Kh});import{existsSync as Y9,readFileSync as g3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as rf}from"path";import{homedir as tf}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*LQ/X);if(J>LQ)J=LQ;let z=LQ-J,K=F8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${W8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function Zh(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -1232,4 +1232,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
1232
1232
|
`),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (h_(),f_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
|
|
1233
1233
|
`),process.stderr.write(v_),2}}cO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var uW0=await mW0(Bun.argv.slice(2));process.exit(uW0);
|
|
1234
1234
|
|
|
1235
|
-
//# debugId=
|
|
1235
|
+
//# debugId=43086F19FDAA53B864756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "8.
|
|
4
|
+
"version": "8.90.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "8.
|
|
5
|
+
"version": "8.90.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a receipt into a portable ATTESTATION a third party can check.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. `loki proof verify` answers "is this receipt good, here, in
|
|
5
|
+
this workspace". That answer is not portable: it needs the original repository.
|
|
6
|
+
The commercial position is being the NOTARY for the category -- verifying
|
|
7
|
+
receipts, including ones this machine did not produce. A notary that needs the
|
|
8
|
+
original workspace is not a notary.
|
|
9
|
+
|
|
10
|
+
So this emits a compact record of WHAT WAS ACTUALLY CHECKED, and by
|
|
11
|
+
construction it cannot claim more than that.
|
|
12
|
+
|
|
13
|
+
THE DEFECT THIS PREVENTS. Three states exist and most systems ship two:
|
|
14
|
+
|
|
15
|
+
VERIFIED checked, passed
|
|
16
|
+
FAILED checked, failed
|
|
17
|
+
UNVERIFIABLE could not be checked here, with the reason
|
|
18
|
+
|
|
19
|
+
Collapsing the third into either of the first two is the whole defect. Folded
|
|
20
|
+
into FAILED, an honest receipt looks forged whenever you read it from the wrong
|
|
21
|
+
directory. Folded into VERIFIED -- the dangerous direction -- an unchecked axis
|
|
22
|
+
acquires a credential it never earned. That is not a missing feature; it is
|
|
23
|
+
laundering uncertainty into a signed-looking claim, which is strictly worse
|
|
24
|
+
than issuing no attestation at all.
|
|
25
|
+
|
|
26
|
+
Nothing here re-implements verification. `verify()` in autonomy/lib/
|
|
27
|
+
proof-verify.py is the single source of truth (it calls verify_integrity()
|
|
28
|
+
internally at line 726 and spreads its fields), and this module's only job is
|
|
29
|
+
to project that result onto the three states without losing the third.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
tools/receipt-attest.py <proof.json> [--json]
|
|
33
|
+
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 every scored axis was checked here and passed
|
|
36
|
+
1 at least one axis FAILED
|
|
37
|
+
2 nothing failed, but at least one axis was UNVERIFIABLE -- including a
|
|
38
|
+
receipt that could not be loaded at all
|
|
39
|
+
|
|
40
|
+
An UNSIGNED receipt does not by itself sink the verdict (see _signature_axis:
|
|
41
|
+
there is nothing to check, and verify() makes the same call upstream), but the
|
|
42
|
+
caveat is carried on the summary line of every verdict so an exit 0 can never
|
|
43
|
+
be mistaken for proof of origin.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import hashlib
|
|
47
|
+
import importlib.util
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import pathlib
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
# A stale .pyc for a hyphenated module loaded by path makes mutation probes
|
|
54
|
+
# report FALSE failures (the probe edits the source, the loader serves the old
|
|
55
|
+
# bytecode). Must be set before the loader below runs.
|
|
56
|
+
sys.dont_write_bytecode = True
|
|
57
|
+
|
|
58
|
+
_LIB = pathlib.Path(__file__).resolve().parents[1] / "autonomy" / "lib"
|
|
59
|
+
_spec = importlib.util.spec_from_file_location(
|
|
60
|
+
"proof_verify", _LIB / "proof-verify.py")
|
|
61
|
+
_pv = importlib.util.module_from_spec(_spec)
|
|
62
|
+
_spec.loader.exec_module(_pv)
|
|
63
|
+
|
|
64
|
+
VERIFIED = "VERIFIED"
|
|
65
|
+
FAILED = "FAILED"
|
|
66
|
+
UNVERIFIABLE = "UNVERIFIABLE"
|
|
67
|
+
|
|
68
|
+
EXIT_OK = 0
|
|
69
|
+
EXIT_FAILED = 1
|
|
70
|
+
EXIT_UNVERIFIABLE = 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tri(value, reason_if_unverifiable):
|
|
74
|
+
"""Project a three-valued verifier field onto the three states.
|
|
75
|
+
|
|
76
|
+
True -> VERIFIED, False -> FAILED, None -> UNVERIFIABLE. `is` comparisons
|
|
77
|
+
are load-bearing: the verifier also uses the truthy string "n/a", and `if
|
|
78
|
+
value:` would read that as a pass.
|
|
79
|
+
"""
|
|
80
|
+
if value is True:
|
|
81
|
+
return {"state": VERIFIED, "reason": ""}
|
|
82
|
+
if value is False:
|
|
83
|
+
return {"state": FAILED, "reason": ""}
|
|
84
|
+
return {"state": UNVERIFIABLE, "reason": reason_if_unverifiable}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pick(reasons, *needles):
|
|
88
|
+
"""First verifier reason mentioning any needle, so an UNVERIFIABLE axis
|
|
89
|
+
carries the verifier's own explanation rather than a generic one."""
|
|
90
|
+
for reason in reasons or []:
|
|
91
|
+
low = reason.lower()
|
|
92
|
+
if any(n in low for n in needles):
|
|
93
|
+
return reason
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _signature_axis(proof, gpg_ok):
|
|
98
|
+
"""Signature status as three states, never a boolean.
|
|
99
|
+
|
|
100
|
+
THE TRAP. _verify_gpg (proof-verify.py:477) returns "n/a" for TWO
|
|
101
|
+
different situations: no signature recorded, AND a signature recorded
|
|
102
|
+
while gpg is absent from PATH. Reading gpg_ok alone therefore reports a
|
|
103
|
+
signed receipt as "unsigned" on any machine without gpg -- the exact
|
|
104
|
+
collapse this file exists to prevent, one layer down. So presence of the
|
|
105
|
+
signature is read from the receipt itself, and only the no-signature case
|
|
106
|
+
is allowed to mean unsigned.
|
|
107
|
+
|
|
108
|
+
WHY UNSIGNED DOES NOT SINK THE VERDICT. "No signature exists" is not "a
|
|
109
|
+
check I could not run" -- there is nothing to verify, so scoring it as
|
|
110
|
+
UNVERIFIABLE would make VERIFIED unreachable for every receipt produced
|
|
111
|
+
without LOKI_PROOF_GPG_KEY, which is the default. verify() already made
|
|
112
|
+
this call upstream: its rollup is `gpg_ok in (True, "n/a")`, and it
|
|
113
|
+
surfaces the caveat as generator_trusted rather than as a failure. This
|
|
114
|
+
projects that decision instead of inventing a stricter one.
|
|
115
|
+
|
|
116
|
+
signed_uncheckable is the opposite case and IS scored: a signature is
|
|
117
|
+
recorded and we genuinely could not evaluate it. Keeping those two apart
|
|
118
|
+
is the whole point of reading the receipt rather than gpg_ok alone.
|
|
119
|
+
"""
|
|
120
|
+
verification = proof.get("verification")
|
|
121
|
+
signature = (verification or {}).get("gpg_signature") if isinstance(
|
|
122
|
+
verification, dict) else None
|
|
123
|
+
|
|
124
|
+
if not signature:
|
|
125
|
+
# Stated, never scored. scored=False keeps this out of the verdict
|
|
126
|
+
# rollup while the caveat still rides along in the summary line, so an
|
|
127
|
+
# exit 0 on an unsigned receipt can never be read as "origin proven".
|
|
128
|
+
return {
|
|
129
|
+
"state": "UNSIGNED",
|
|
130
|
+
"status": "unsigned",
|
|
131
|
+
"scored": False,
|
|
132
|
+
"reason": "the receipt carries no gpg signature, so its origin "
|
|
133
|
+
"rests on the generator that produced it, not on "
|
|
134
|
+
"cryptographic proof",
|
|
135
|
+
}
|
|
136
|
+
if gpg_ok is True:
|
|
137
|
+
return {"state": VERIFIED, "status": "signed_valid", "scored": True,
|
|
138
|
+
"reason": ""}
|
|
139
|
+
if gpg_ok is False:
|
|
140
|
+
return {
|
|
141
|
+
"state": FAILED,
|
|
142
|
+
"status": "signed_invalid",
|
|
143
|
+
"scored": True,
|
|
144
|
+
"reason": "a gpg signature is recorded but does not verify "
|
|
145
|
+
"against the canonical receipt bytes",
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
"state": UNVERIFIABLE,
|
|
149
|
+
"status": "signed_uncheckable",
|
|
150
|
+
"scored": True,
|
|
151
|
+
"reason": "a gpg signature is recorded but gpg is not available "
|
|
152
|
+
"here, so it could be neither confirmed nor refuted",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def attest(proof_path, repo_dir="."):
|
|
157
|
+
"""Build the attestation record. Pure: no writes, no network."""
|
|
158
|
+
try:
|
|
159
|
+
proof = _pv._load_proof(proof_path)
|
|
160
|
+
except _pv.ProofLoadError as exc:
|
|
161
|
+
return {
|
|
162
|
+
"attestation": "loki-receipt-attestation/v1",
|
|
163
|
+
"receipt": os.path.basename(str(proof_path)),
|
|
164
|
+
"receipt_sha256": None,
|
|
165
|
+
"checked_from": os.path.abspath(repo_dir),
|
|
166
|
+
"axes": {},
|
|
167
|
+
"signature": {
|
|
168
|
+
"state": UNVERIFIABLE,
|
|
169
|
+
"status": "unsigned",
|
|
170
|
+
"reason": "the receipt could not be loaded",
|
|
171
|
+
},
|
|
172
|
+
"verdict": UNVERIFIABLE,
|
|
173
|
+
"summary": "UNVERIFIABLE -- %s" % exc,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
result = _pv.verify(proof_path, repo_dir)
|
|
177
|
+
reasons = result.get("reasons") or []
|
|
178
|
+
|
|
179
|
+
# The receipt's own identity. Hash the canonical bytes the verifier
|
|
180
|
+
# hashes (verification stripped), so an attestation is comparable across
|
|
181
|
+
# machines regardless of key ordering or whitespace in the file.
|
|
182
|
+
unsigned = dict(proof)
|
|
183
|
+
unsigned.pop("verification", None)
|
|
184
|
+
receipt_sha = hashlib.sha256(
|
|
185
|
+
_pv._canonical(unsigned).encode("utf-8")).hexdigest()
|
|
186
|
+
|
|
187
|
+
axes = {}
|
|
188
|
+
|
|
189
|
+
# Integrity: hash_ok is a plain bool from the verifier -- always checked.
|
|
190
|
+
axes["integrity"] = _tri(bool(result.get("hash_ok")), "")
|
|
191
|
+
if axes["integrity"]["state"] == FAILED:
|
|
192
|
+
axes["integrity"]["reason"] = _pick(
|
|
193
|
+
reasons, "hash mismatch", "integrity hash") or \
|
|
194
|
+
"the recorded integrity hash does not match the receipt bytes"
|
|
195
|
+
|
|
196
|
+
# Drift: the axis that genuinely cannot be checked away from the repo.
|
|
197
|
+
axes["drift"] = _tri(
|
|
198
|
+
result.get("diff_drift") is False if result.get("diff_drift") is not None
|
|
199
|
+
else None,
|
|
200
|
+
_pick(reasons, "drift unverifiable") or
|
|
201
|
+
"the recorded diff could not be re-derived here",
|
|
202
|
+
)
|
|
203
|
+
if result.get("diff_drift") is True:
|
|
204
|
+
axes["drift"] = {
|
|
205
|
+
"state": FAILED,
|
|
206
|
+
"reason": _pick(reasons, "diff drift") or
|
|
207
|
+
"the recorded diff no longer matches the repository",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Headline consistency and cost coherence are None when not derivable.
|
|
211
|
+
axes["headline"] = _tri(
|
|
212
|
+
result.get("headline_consistent"),
|
|
213
|
+
"the receipt records no headline or no facts to re-derive it from",
|
|
214
|
+
)
|
|
215
|
+
if axes["headline"]["state"] == FAILED:
|
|
216
|
+
axes["headline"]["reason"] = _pick(reasons, "headline") or \
|
|
217
|
+
"the headline disagrees with the recorded facts"
|
|
218
|
+
|
|
219
|
+
axes["cost"] = _tri(
|
|
220
|
+
result.get("cost_coherent"),
|
|
221
|
+
"the receipt records no cost block to check for coherence",
|
|
222
|
+
)
|
|
223
|
+
if axes["cost"]["state"] == FAILED:
|
|
224
|
+
axes["cost"]["reason"] = _pick(reasons, "cost claim") or \
|
|
225
|
+
"the recorded cost contradicts itself"
|
|
226
|
+
|
|
227
|
+
# Workspace tree. Every real receipt carries one (proof-generator.py:1190
|
|
228
|
+
# writes tree_sha256 unconditionally), and verify() scores it. Dropping it
|
|
229
|
+
# here meant a receipt verify() REJECTED for tree drift still attested
|
|
230
|
+
# VERIFIED with exit 0 -- an axis silently defaulting to "passed" purely
|
|
231
|
+
# because the projection forgot it, which is the exact laundering this
|
|
232
|
+
# tool exists to prevent.
|
|
233
|
+
#
|
|
234
|
+
# tree_drift is None both when nothing was recorded and when the digest
|
|
235
|
+
# could not be recomputed, so disambiguate on the recorded value: no
|
|
236
|
+
# recorded digest is not an axis at all (nothing to check, like unsigned),
|
|
237
|
+
# while a recorded digest we could not recompute is genuinely
|
|
238
|
+
# UNVERIFIABLE.
|
|
239
|
+
recorded_tree = (result.get("tree_recheck") or {}).get("recorded")
|
|
240
|
+
if recorded_tree:
|
|
241
|
+
axes["tree"] = _tri(
|
|
242
|
+
result.get("tree_drift") is False
|
|
243
|
+
if result.get("tree_drift") is not None else None,
|
|
244
|
+
_pick(reasons, "tree unverifiable", "workspace tree unverifiable")
|
|
245
|
+
or "the receipt records a workspace tree digest, but the current "
|
|
246
|
+
"tree could not be re-derived here",
|
|
247
|
+
)
|
|
248
|
+
if result.get("tree_drift") is True:
|
|
249
|
+
axes["tree"] = {
|
|
250
|
+
"state": FAILED,
|
|
251
|
+
"reason": _pick(reasons, "workspace tree drift") or
|
|
252
|
+
"the recorded workspace tree digest no longer matches",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
signature = _signature_axis(proof, result.get("gpg_ok"))
|
|
256
|
+
|
|
257
|
+
scored = dict(axes)
|
|
258
|
+
if signature.get("scored"):
|
|
259
|
+
scored["signature"] = signature
|
|
260
|
+
|
|
261
|
+
states = [a["state"] for a in scored.values()]
|
|
262
|
+
if FAILED in states:
|
|
263
|
+
verdict = FAILED
|
|
264
|
+
elif UNVERIFIABLE in states:
|
|
265
|
+
verdict = UNVERIFIABLE
|
|
266
|
+
else:
|
|
267
|
+
verdict = VERIFIED
|
|
268
|
+
|
|
269
|
+
failed = sorted(k for k, a in scored.items() if a["state"] == FAILED)
|
|
270
|
+
unchecked = sorted(k for k, a in scored.items()
|
|
271
|
+
if a["state"] == UNVERIFIABLE)
|
|
272
|
+
passed = sorted(k for k, a in scored.items() if a["state"] == VERIFIED)
|
|
273
|
+
|
|
274
|
+
# The unsigned caveat rides on every verdict, including a passing one. An
|
|
275
|
+
# exit 0 is only honest if the thing it does NOT cover is impossible to
|
|
276
|
+
# miss on the line a human actually reads.
|
|
277
|
+
caveat = ("; receipt is UNSIGNED, so origin rests on its generator"
|
|
278
|
+
if signature["status"] == "unsigned" else "")
|
|
279
|
+
|
|
280
|
+
if verdict == FAILED:
|
|
281
|
+
summary = "FAILED -- %s did not pass here%s" % (
|
|
282
|
+
", ".join(failed), caveat)
|
|
283
|
+
elif verdict == UNVERIFIABLE:
|
|
284
|
+
summary = "UNVERIFIABLE -- %s passed, but %s could not be checked here%s" % (
|
|
285
|
+
", ".join(passed) or "nothing", ", ".join(unchecked), caveat)
|
|
286
|
+
else:
|
|
287
|
+
summary = "VERIFIED -- every axis checked here passed%s" % caveat
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"attestation": "loki-receipt-attestation/v1",
|
|
291
|
+
"receipt": os.path.basename(str(proof_path)),
|
|
292
|
+
"receipt_sha256": receipt_sha,
|
|
293
|
+
"checked_from": os.path.abspath(repo_dir),
|
|
294
|
+
"axes": axes,
|
|
295
|
+
"signature": signature,
|
|
296
|
+
# Carried straight from verify(): True means the facts are taken at
|
|
297
|
+
# face value because no valid signature vouches for them.
|
|
298
|
+
"generator_trusted": bool(result.get("generator_trusted")),
|
|
299
|
+
"verdict": verdict,
|
|
300
|
+
"summary": summary,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def exit_code(record):
|
|
305
|
+
"""Exit 0 ONLY when nothing failed and nothing was left unchecked.
|
|
306
|
+
|
|
307
|
+
An UNVERIFIABLE axis must not exit 0: a confident 0 is what a caller in a
|
|
308
|
+
`&&` chain reads as "attested", which would re-collapse the third state at
|
|
309
|
+
the shell boundary after this module worked to keep it separate. It gets
|
|
310
|
+
its own code rather than sharing 1, so a consumer can distinguish "this
|
|
311
|
+
receipt is bad" (1) from "fetch the repo and re-run" (2).
|
|
312
|
+
"""
|
|
313
|
+
if record.get("verdict") == FAILED:
|
|
314
|
+
return EXIT_FAILED
|
|
315
|
+
if record.get("verdict") == UNVERIFIABLE:
|
|
316
|
+
return EXIT_UNVERIFIABLE
|
|
317
|
+
return EXIT_OK
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def render(record):
|
|
321
|
+
lines = [record["summary"], ""]
|
|
322
|
+
lines.append("receipt %s" % record["receipt"])
|
|
323
|
+
lines.append("sha256 %s" % (record["receipt_sha256"] or "unknown"))
|
|
324
|
+
lines.append("checked from %s" % record["checked_from"])
|
|
325
|
+
lines.append("")
|
|
326
|
+
for name, axis in record["axes"].items():
|
|
327
|
+
lines.append("%-10s %s" % (name, axis["state"]))
|
|
328
|
+
if axis.get("reason"):
|
|
329
|
+
lines.append(" %s" % axis["reason"])
|
|
330
|
+
sig = record["signature"]
|
|
331
|
+
lines.append("%-10s %s (%s)" % ("signature", sig["state"], sig["status"]))
|
|
332
|
+
if sig.get("reason"):
|
|
333
|
+
lines.append(" %s" % sig["reason"])
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main(argv):
|
|
338
|
+
args = [a for a in argv[1:] if a != "--json"]
|
|
339
|
+
as_json = "--json" in argv[1:]
|
|
340
|
+
if len(args) != 1:
|
|
341
|
+
sys.stderr.write("usage: receipt-attest.py <proof.json> [--json]\n")
|
|
342
|
+
return 64
|
|
343
|
+
record = attest(args[0])
|
|
344
|
+
print(json.dumps(record, indent=2, sort_keys=True) if as_json
|
|
345
|
+
else render(record))
|
|
346
|
+
return exit_code(record)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
sys.exit(main(sys.argv))
|