loki-mode 8.88.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 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.88.0
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.88.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.90.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.88.0
1
+ 8.90.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.88.0"
10
+ __version__ = "8.90.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.88.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}
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=9115B3EA38A3D81564756E2164756E21
1235
+ //# debugId=43086F19FDAA53B864756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '8.88.0'
78
+ __version__ = '8.90.0'
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.88.0",
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.88.0",
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,340 @@
1
+ #!/usr/bin/env bash
2
+ # Will this run succeed, and what will it likely cost? Answered BEFORE the run.
3
+ #
4
+ # WHY THIS EXISTS. The signals that predict a doomed or expensive build already
5
+ # exist, but a user has to know four commands to collect them, and three of the
6
+ # four are easy to reach for in a form that MUTATES. This is one read-only
7
+ # command that composes them into a single verdict.
8
+ #
9
+ # COMPOSE, NEVER REIMPLEMENT. Every number and every fix string below comes out
10
+ # of the surface that owns it. This repo has already paid for the alternative:
11
+ # four separate provider lists drifted apart, and "unmeasured" was rendered as
12
+ # "$0.00" on four surfaces before the estimator centralised the predicate. So:
13
+ #
14
+ # provider selection providers/loader.sh auto_detect_provider (the ONE list)
15
+ # blockers + fixes loki doctor --json (its detail strings, verbatim)
16
+ # resumable run loki next --dry-run (read-only; loki resume DELETES the
17
+ # PAUSE file, so it can never be the seam a preflight uses)
18
+ # projected cost tools/estimate-run.py --json (its notes, verbatim)
19
+ #
20
+ # When a signal is absent this prints UNKNOWN and says why. It never prints 0
21
+ # for something it did not measure, and it never invents the iteration horizon:
22
+ # --iterations is the estimator's REQUIRED input precisely so a guess cannot be
23
+ # laundered through a real per-iteration cost, and passing a default here would
24
+ # reintroduce that on this surface.
25
+ #
26
+ # Read-only: starts nothing, spends nothing, contacts no provider.
27
+
28
+ set -uo pipefail
29
+
30
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
32
+
33
+ # ponytail: overridable so the "missing helper degrades to unavailable" path is
34
+ # testable without moving repo files out from under a live install.
35
+ LOKI_PREFLIGHT_DOCTOR="${LOKI_PREFLIGHT_DOCTOR:-$REPO_ROOT/autonomy/loki}"
36
+ LOKI_PREFLIGHT_ESTIMATOR="${LOKI_PREFLIGHT_ESTIMATOR:-$REPO_ROOT/tools/estimate-run.py}"
37
+ LOKI_PREFLIGHT_LOADER="${LOKI_PREFLIGHT_LOADER:-$REPO_ROOT/providers/loader.sh}"
38
+
39
+ BOLD='\033[1m'; RED='\033[0;31m'; GREEN='\033[0;32m'
40
+ YELLOW='\033[1;33m'; DIM='\033[2m'; NC='\033[0m'
41
+
42
+ # timeout(1) is coreutils; on macOS it exists only via homebrew. Hard-coding it
43
+ # made this script exit 127 on a stock mac -- and made the scrubbed-PATH test
44
+ # impossible to run at all. Degrade to running the helper uncapped rather than
45
+ # failing the whole preflight over a missing convenience.
46
+ _run_capped() {
47
+ local secs="$1"; shift
48
+ if command -v timeout >/dev/null 2>&1; then
49
+ timeout "$secs" "$@"
50
+ elif command -v gtimeout >/dev/null 2>&1; then
51
+ gtimeout "$secs" "$@"
52
+ else
53
+ "$@"
54
+ fi
55
+ }
56
+
57
+ ITERATIONS=""
58
+ WORKSPACE="."
59
+
60
+ while [ "$#" -gt 0 ]; do
61
+ case "$1" in
62
+ --iterations)
63
+ # An empty value must ERROR, not silently read as "flag absent" --
64
+ # that would quietly downgrade a requested projection to UNKNOWN and
65
+ # look like missing history rather than a typo.
66
+ if [ -z "${2:-}" ]; then
67
+ echo "preflight: --iterations requires a value" >&2; exit 64
68
+ fi
69
+ ITERATIONS="$2"; shift 2 ;;
70
+ --iterations=*)
71
+ ITERATIONS="${1#*=}"
72
+ if [ -z "$ITERATIONS" ]; then
73
+ echo "preflight: --iterations requires a value" >&2; exit 64
74
+ fi
75
+ shift ;;
76
+ --help|-h)
77
+ echo "loki preflight -- will this run succeed, and what will it cost?"
78
+ echo ""
79
+ echo "Usage: tools/preflight.sh [WORKSPACE] [--iterations N]"
80
+ echo ""
81
+ echo "Read-only. Starts nothing, spends nothing, contacts no provider."
82
+ echo ""
83
+ echo " --iterations N Project cost over N iterations. Without it the"
84
+ echo " measured per-iteration cost is still shown; the"
85
+ echo " total reads UNKNOWN rather than being guessed."
86
+ exit 0 ;;
87
+ -*)
88
+ echo "preflight: unknown option: $1" >&2; exit 64 ;;
89
+ *)
90
+ WORKSPACE="$1"; shift ;;
91
+ esac
92
+ done
93
+
94
+ if [ -n "$ITERATIONS" ] && ! [[ "$ITERATIONS" =~ ^[1-9][0-9]*$ ]]; then
95
+ echo "preflight: --iterations must be a positive integer, got: $ITERATIONS" >&2
96
+ exit 64
97
+ fi
98
+
99
+ if [ ! -d "$WORKSPACE" ]; then
100
+ echo "preflight: no such workspace: $WORKSPACE" >&2
101
+ exit 66
102
+ fi
103
+
104
+ BLOCKERS=() # each entry: "what blocks|the command that fixes it"
105
+ WARNINGS=()
106
+
107
+ echo -e "${BOLD}Loki preflight${NC} -- $(cd "$WORKSPACE" && pwd)"
108
+ echo -e "${DIM}Read-only: starts nothing, spends nothing, contacts no provider.${NC}"
109
+ echo ""
110
+
111
+ # ---- Provider ---------------------------------------------------------------
112
+ # auto_detect_provider is THE priority list. Restating the order here is how the
113
+ # four-lists-disagree bug happened, so this sources it and asks.
114
+ echo -e "${BOLD}Provider${NC}"
115
+ PROVIDER=""
116
+ if [ -r "$LOKI_PREFLIGHT_LOADER" ]; then
117
+ # shellcheck disable=SC1090
118
+ if source "$LOKI_PREFLIGHT_LOADER" >/dev/null 2>&1 \
119
+ && declare -f auto_detect_provider >/dev/null 2>&1; then
120
+ PROVIDER="$(auto_detect_provider 2>/dev/null || true)"
121
+ if [ -n "$PROVIDER" ]; then
122
+ echo -e " ${GREEN}OK${NC} auto-detection would select: ${BOLD}${PROVIDER}${NC}"
123
+ else
124
+ echo -e " ${RED}FAIL${NC} no provider CLI installed"
125
+ fi
126
+ else
127
+ echo -e " ${YELLOW}UNKNOWN${NC} provider loader unreadable -- selection not determined"
128
+ WARNINGS+=("provider selection unavailable: could not load $LOKI_PREFLIGHT_LOADER")
129
+ fi
130
+ else
131
+ echo -e " ${YELLOW}UNKNOWN${NC} provider loader missing -- selection not determined"
132
+ WARNINGS+=("provider selection unavailable: $LOKI_PREFLIGHT_LOADER not found")
133
+ fi
134
+ echo ""
135
+
136
+ # ---- Doctor blockers --------------------------------------------------------
137
+ # --json only. The TEXT path backgrounds a first-run-blocked telemetry emit on
138
+ # failure, which a read-only preflight must not trigger.
139
+ echo -e "${BOLD}Environment${NC}"
140
+ DOCTOR_JSON=""
141
+ if [ -x "$LOKI_PREFLIGHT_DOCTOR" ] || [ -r "$LOKI_PREFLIGHT_DOCTOR" ]; then
142
+ DOCTOR_JSON="$(cd "$WORKSPACE" && _run_capped 120 bash "$LOKI_PREFLIGHT_DOCTOR" doctor --json 2>/dev/null || true)"
143
+ fi
144
+ if [ -z "$DOCTOR_JSON" ]; then
145
+ echo -e " ${YELLOW}UNKNOWN${NC} doctor unavailable -- environment blockers not checked"
146
+ WARNINGS+=("environment check unavailable: could not run '$LOKI_PREFLIGHT_DOCTOR doctor --json'")
147
+ else
148
+ # Surface doctor's OWN blocker text. Re-authoring the fix line is how the
149
+ # advice drifts from the thing that actually installs the dependency.
150
+ DOCTOR_REPORT="$(printf '%s' "$DOCTOR_JSON" | python3 -c '
151
+ import json, sys
152
+ try:
153
+ d = json.load(sys.stdin)
154
+ except Exception:
155
+ sys.exit(3)
156
+ lines = []
157
+ for c in d.get("checks") or []:
158
+ if c.get("status") == "fail":
159
+ name = c.get("name") or c.get("command") or "check"
160
+ cmd = c.get("command") or ""
161
+ lines.append("BLOCK\t%s is missing or too old\tInstall %s, then re-run: loki doctor" % (name, cmd or name))
162
+ ai = d.get("ai_provider") or {}
163
+ if ai.get("status") == "fail":
164
+ detail = ai.get("detail") or "No AI provider CLI."
165
+ what, _, fix = detail.partition("Fix:")
166
+ lines.append("BLOCK\t%s\t%s" % (what.strip().rstrip(".") or "No AI provider CLI",
167
+ fix.strip() or "install a provider CLI"))
168
+ disk = d.get("disk") or {}
169
+ if disk.get("status") == "fail":
170
+ lines.append("BLOCK\tLow disk space (%s GB available)\tFree up disk, then re-run: loki doctor"
171
+ % disk.get("available_gb"))
172
+ s = d.get("summary") or {}
173
+ lines.append("SUMMARY\t%s\t%s" % (s.get("passed"), s.get("warnings")))
174
+ print("\n".join(lines))
175
+ ' 2>/dev/null || true)"
176
+
177
+ if [ -z "$DOCTOR_REPORT" ]; then
178
+ echo -e " ${YELLOW}UNKNOWN${NC} doctor output unparseable -- environment blockers not checked"
179
+ WARNINGS+=("environment check unavailable: doctor --json produced no readable result")
180
+ else
181
+ _env_blocks=0
182
+ while IFS=$'\t' read -r kind a b; do
183
+ case "$kind" in
184
+ BLOCK)
185
+ # THE TWO LISTS DISAGREE, and preflight is the first surface
186
+ # that shows both at once. auto_detect_provider iterates
187
+ # claude cline codex aider opencode; doctor's ai_provider
188
+ # checks only the first four. On an opencode-only machine
189
+ # this printed "would select: opencode" directly above
190
+ # "No AI provider CLI" -- a self-contradicting report.
191
+ # Neither producer is editable from here, so the
192
+ # disagreement is NAMED rather than silently resolved:
193
+ # asserting either side is right would be a fifth opinion
194
+ # about which providers count, which is how the four lists
195
+ # drifted apart in the first place.
196
+ if [ "$a" = "No AI provider CLI" ] && [ -n "$PROVIDER" ]; then
197
+ echo -e " ${YELLOW}WARN${NC} doctor reports no provider, but auto-detection selected '${PROVIDER}'"
198
+ echo -e " ${DIM} doctor does not recognise '${PROVIDER}'. Unresolved here: these two${NC}"
199
+ echo -e " ${DIM} checks disagree, so provider readiness is UNCERTAIN, not confirmed.${NC}"
200
+ WARNINGS+=("provider readiness uncertain: auto-detection selected '$PROVIDER' but doctor does not count it as a provider")
201
+ else
202
+ echo -e " ${RED}FAIL${NC} $a"
203
+ BLOCKERS+=("$a|$b")
204
+ _env_blocks=$((_env_blocks + 1))
205
+ fi ;;
206
+ SUMMARY)
207
+ if [ "$_env_blocks" -eq 0 ]; then
208
+ echo -e " ${GREEN}OK${NC} doctor: $a checks passed, $b warning(s)"
209
+ fi ;;
210
+ esac
211
+ done <<< "$DOCTOR_REPORT"
212
+ fi
213
+ fi
214
+
215
+ # A provider is required to run at all. Doctor reports it too, so only add this
216
+ # when doctor could not be consulted -- otherwise the same blocker lists twice.
217
+ if [ -z "$PROVIDER" ] && [ -r "$LOKI_PREFLIGHT_LOADER" ] && [ -z "$DOCTOR_JSON" ]; then
218
+ BLOCKERS+=("No AI provider CLI|npm install -g @anthropic-ai/claude-code")
219
+ fi
220
+ echo ""
221
+
222
+ # ---- Resumable run ----------------------------------------------------------
223
+ # loki next --dry-run resolves the saved run status and prints the exact
224
+ # continuation command WITHOUT acting. loki resume would rm the PAUSE file.
225
+ echo -e "${BOLD}Saved state${NC}"
226
+ if [ ! -d "$WORKSPACE/.loki" ]; then
227
+ echo -e " ${DIM}no .loki/ here -- this would be a fresh run${NC}"
228
+ elif [ -z "$DOCTOR_JSON" ] && [ ! -r "$LOKI_PREFLIGHT_DOCTOR" ]; then
229
+ echo -e " ${YELLOW}UNKNOWN${NC} cannot resolve saved run state (loki CLI unavailable)"
230
+ WARNINGS+=("saved-state check unavailable: $LOKI_PREFLIGHT_DOCTOR not found")
231
+ else
232
+ NEXT_OUT="$(cd "$WORKSPACE" && _run_capped 60 bash "$LOKI_PREFLIGHT_DOCTOR" next --dry-run 2>/dev/null || true)"
233
+ if [ -z "$NEXT_OUT" ]; then
234
+ echo -e " ${DIM}no resumable run found${NC}"
235
+ else
236
+ _outcome="$(printf '%s' "$NEXT_OUT" | sed -n 's/.*Last outcome *: *//p' | head -1)"
237
+ _will="$(printf '%s' "$NEXT_OUT" | sed -n 's/.*Will run *: *//p' | head -1)"
238
+ if [ -n "$_outcome" ]; then
239
+ echo -e " ${YELLOW}FOUND${NC} interrupted or paused run present (last outcome: ${BOLD}${_outcome}${NC})"
240
+ [ -n "$_will" ] && echo -e " continue it with: ${BOLD}${_will}${NC}"
241
+ WARNINGS+=("a resumable run exists here; starting fresh would leave it behind")
242
+ else
243
+ echo -e " ${DIM}no resumable run found${NC}"
244
+ fi
245
+ fi
246
+ fi
247
+ echo ""
248
+
249
+ # ---- Projected cost ---------------------------------------------------------
250
+ echo -e "${BOLD}Projected cost${NC}"
251
+ if [ ! -r "$LOKI_PREFLIGHT_ESTIMATOR" ]; then
252
+ echo -e " ${YELLOW}UNKNOWN${NC} estimator unavailable -- no cost projection"
253
+ WARNINGS+=("cost projection unavailable: $LOKI_PREFLIGHT_ESTIMATOR not found")
254
+ else
255
+ # --iterations is REQUIRED by the estimator by design. Passing 1 here just to
256
+ # satisfy it and then scaling would be the guess it refuses to make, so when
257
+ # the user gave none we ask for the per-iteration basis only and report the
258
+ # total as UNKNOWN.
259
+ _est_iters="${ITERATIONS:-1}"
260
+ EST_JSON="$(_run_capped 60 python3 "$LOKI_PREFLIGHT_ESTIMATOR" --iterations "$_est_iters" "$WORKSPACE" --json 2>/dev/null || true)"
261
+ if [ -z "$EST_JSON" ]; then
262
+ echo -e " ${YELLOW}UNKNOWN${NC} estimator produced no result -- no cost projection"
263
+ WARNINGS+=("cost projection unavailable: estimator did not return a result")
264
+ else
265
+ # The estimator's own notes carry the "not \$0.00" wording. Printed
266
+ # verbatim so this surface cannot drift from the rule it inherits.
267
+ printf '%s' "$EST_JSON" | LOKI_PF_ITERS="${ITERATIONS:-}" python3 -c '
268
+ import json, os, sys
269
+ try:
270
+ d = json.load(sys.stdin)
271
+ except Exception:
272
+ print(" UNKNOWN estimator output unreadable -- no cost projection")
273
+ sys.exit(0)
274
+ iters = os.environ.get("LOKI_PF_ITERS") or ""
275
+ if not d.get("has_basis"):
276
+ print(" UNKNOWN no measured, priced iteration in this workspace")
277
+ for n in d.get("notes") or []:
278
+ print(" " + n)
279
+ else:
280
+ med = d.get("median_cost_per_iteration_usd")
281
+ print(" measured per-iteration cost: $%.4f (median of %d priced iteration(s))"
282
+ % (med, d.get("basis_count") or 0))
283
+ if d.get("single_point_basis"):
284
+ print(" basis is a SINGLE data point -- arithmetically fine,")
285
+ print(" epistemically weak; treat the total as indicative only")
286
+ if iters:
287
+ proj = d.get("projected_cost_usd")
288
+ # Explicit None check. "or 0.0" would render an absent projection as
289
+ # $0.00 -- the exact defect this surface exists to refuse, and it would
290
+ # slip in on the one branch that prints a real dollar figure.
291
+ if proj is None:
292
+ print(" projected total: UNKNOWN -- estimator returned no projection")
293
+ else:
294
+ print(" projected for %s iteration(s): $%.2f [ESTIMATE, not a guarantee]"
295
+ % (iters, proj))
296
+ else:
297
+ print(" projected total: UNKNOWN -- pass --iterations N to project a total")
298
+ print(" (the horizon is not guessed: an invented N would launder")
299
+ print(" a guess through a real per-iteration cost)")
300
+ '
301
+ fi
302
+ fi
303
+ echo ""
304
+
305
+ # ---- Verdict ----------------------------------------------------------------
306
+ echo -e "${BOLD}========================================${NC}"
307
+ if [ "${#BLOCKERS[@]}" -gt 0 ]; then
308
+ echo -e "${RED}${BOLD}VERDICT: BLOCKED${NC}"
309
+ echo ""
310
+ echo "This run cannot succeed until these are fixed:"
311
+ for b in "${BLOCKERS[@]}"; do
312
+ echo -e " - ${BOLD}${b%%|*}${NC}"
313
+ echo -e " fix: ${b#*|}"
314
+ done
315
+ # Warnings are printed under BLOCKED too. Showing them only on the READY
316
+ # paths meant a blocked run silently DROPPED every uncertain signal -- the
317
+ # user fixes the named blocker, re-runs, and only then discovers a second
318
+ # problem that was already known. An unmeasured signal must not become
319
+ # invisible just because something else failed first.
320
+ if [ "${#WARNINGS[@]}" -gt 0 ]; then
321
+ echo ""
322
+ echo "Also uncertain (not blocking):"
323
+ for w in "${WARNINGS[@]}"; do
324
+ echo " - $w"
325
+ done
326
+ fi
327
+ echo ""
328
+ echo "Then re-run: tools/preflight.sh"
329
+ exit 1
330
+ elif [ "${#WARNINGS[@]}" -gt 0 ]; then
331
+ echo -e "${YELLOW}${BOLD}VERDICT: READY WITH WARNINGS${NC}"
332
+ echo ""
333
+ for w in "${WARNINGS[@]}"; do
334
+ echo " - $w"
335
+ done
336
+ exit 0
337
+ else
338
+ echo -e "${GREEN}${BOLD}VERDICT: READY${NC}"
339
+ exit 0
340
+ fi
@@ -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))