loki-mode 8.95.0 → 8.97.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/cost-history.py +317 -0
- package/tools/gate-report.py +248 -0
- package/tools/model-advisor.py +13 -2
- package/tools/policy-load.py +196 -0
- package/tools/signing-status.py +274 -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.97.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.97.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
8.
|
|
1
|
+
8.97.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.97.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=B171B033D661F73464756E2164756E21
|
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.97.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.97.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,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Track agent cost across MANY runs and report the trend.
|
|
3
|
+
|
|
4
|
+
cost-guard.py gates ONE run against a ceiling. receipt-diff.py compares TWO
|
|
5
|
+
runs. Neither answers the question a team actually asks at the end of a month:
|
|
6
|
+
is our agent spend trending up? That needs a durable history, so this appends
|
|
7
|
+
one line per run to a JSONL file and reports over the whole series.
|
|
8
|
+
|
|
9
|
+
THE FIVE PROPERTIES THAT MAKE THE TREND WORTH BELIEVING:
|
|
10
|
+
|
|
11
|
+
1. AN UNMEASURED RUN IS NEVER RECORDED AS 0. It is appended with usd=null and
|
|
12
|
+
excluded from the trend. Recording zero would drag the median down and
|
|
13
|
+
understate real spend -- the exact "$0.00 means free" lie this repo spent
|
|
14
|
+
thirteen surfaces removing. Whether a number counts as measured is
|
|
15
|
+
record_is_measured() in autonomy/lib/efficiency_cost.py, imported and never
|
|
16
|
+
restated; receipts store the figure under cost.usd while the predicate reads
|
|
17
|
+
cost_usd, so the KEY is mapped here. Mapping a key name is not restating the
|
|
18
|
+
rule.
|
|
19
|
+
|
|
20
|
+
RECORD-NULL rather than REFUSE, deliberately. Refusing is cost-guard.py's
|
|
21
|
+
job and it already does it, per run, with an exit code. Here the history IS
|
|
22
|
+
the product, and a refused append leaves no trace: a month with four broken
|
|
23
|
+
instrumentation runs would look identical to a month with eight clean ones.
|
|
24
|
+
The null row is countable, so the report can say "12 runs, 8 measured" and
|
|
25
|
+
the operator can see the measurement gap instead of inferring it from a
|
|
26
|
+
short file.
|
|
27
|
+
|
|
28
|
+
2. A TREND FROM ONE POINT IS NOT A TREND. Fewer than 2 measured runs reads
|
|
29
|
+
INSUFFICIENT DATA. "Flat" is a claim about change over time and one
|
|
30
|
+
observation cannot support it.
|
|
31
|
+
|
|
32
|
+
3. APPEND IS ATOMIC PER LINE. See _append().
|
|
33
|
+
|
|
34
|
+
4. A CORRUPT LINE IS COUNTED AND REPORTED, never silently skipped. A history
|
|
35
|
+
that quietly drops rows reports a cleaner trend than reality, and drops them
|
|
36
|
+
most often when something upstream is broken -- precisely when the number
|
|
37
|
+
matters.
|
|
38
|
+
|
|
39
|
+
5. AN EMPTY HISTORY IS NOT A FLAT TREND. It exits non-zero. Zero runs is an
|
|
40
|
+
absent measurement, not evidence of stability.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
tools/cost-history.py record [workspace] [--file .loki/cost-history.jsonl]
|
|
44
|
+
tools/cost-history.py report [--file ...] [--json]
|
|
45
|
+
|
|
46
|
+
Exit: 0 report produced / run recorded, 1 nothing to report (empty or no
|
|
47
|
+
measured runs), 2 cannot record.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
57
|
+
sys.path.insert(0, os.path.join(os.path.dirname(_HERE), "autonomy", "lib"))
|
|
58
|
+
|
|
59
|
+
from efficiency_cost import collect_efficiency, record_is_measured # noqa: E402
|
|
60
|
+
|
|
61
|
+
OK, NOTHING, CANNOT = 0, 1, 2
|
|
62
|
+
|
|
63
|
+
DEFAULT_FILE = os.path.join(".loki", "cost-history.jsonl")
|
|
64
|
+
|
|
65
|
+
# Below this the two halves are the same number to the cent, and calling that
|
|
66
|
+
# a direction is noise dressed as signal.
|
|
67
|
+
FLAT_PCT = 5.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _num(v):
|
|
71
|
+
"""A number as itself; None, "", or a bool as None."""
|
|
72
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
73
|
+
return None
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def measured_usd(cost):
|
|
78
|
+
"""The USD figure from a cost block, or None when it was never measured.
|
|
79
|
+
|
|
80
|
+
`available` is NOT trusted on its own: a real receipt shipped
|
|
81
|
+
available=true with every field zero (v8.52.0), so the flag is a claim and
|
|
82
|
+
the VALUES are the evidence.
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(cost, dict):
|
|
85
|
+
return None
|
|
86
|
+
rec = {
|
|
87
|
+
"cost_usd": _num(cost.get("usd")),
|
|
88
|
+
"input_tokens": _num(cost.get("input_tokens")),
|
|
89
|
+
"output_tokens": _num(cost.get("output_tokens")),
|
|
90
|
+
"cache_read_tokens": _num(cost.get("cache_read_tokens")),
|
|
91
|
+
"cache_creation_tokens": _num(cost.get("cache_creation_tokens")),
|
|
92
|
+
}
|
|
93
|
+
if not record_is_measured(rec):
|
|
94
|
+
return None
|
|
95
|
+
return _num(rec["cost_usd"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _loki_dir(workspace):
|
|
99
|
+
"""Accept either a workspace root or a .loki dir; collect_ wants .loki."""
|
|
100
|
+
if os.path.basename(os.path.normpath(workspace)) == ".loki":
|
|
101
|
+
return workspace
|
|
102
|
+
return os.path.join(workspace, ".loki")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _append(path, entry):
|
|
106
|
+
"""Append ONE newline-terminated line with ONE write() call, in "a" mode.
|
|
107
|
+
|
|
108
|
+
WHY THAT IS SUFFICIENT HERE, rather than the usual write-temp-and-rename.
|
|
109
|
+
POSIX gives an O_APPEND write a seek-to-end that cannot be interleaved, so
|
|
110
|
+
concurrent appenders cannot overwrite each other, and a line small enough
|
|
111
|
+
to land in a single write is not torn between two of them. Rename-based
|
|
112
|
+
atomicity would be strictly WORSE for this file: it rewrites the whole
|
|
113
|
+
history every time, which turns a crash into the loss of every prior run
|
|
114
|
+
instead of a partial final line.
|
|
115
|
+
|
|
116
|
+
That partial final line is the residual risk and it is handled rather than
|
|
117
|
+
prevented: a torn or truncated row is COUNTED as corrupt by load() and
|
|
118
|
+
named in the report. This is why the newline terminator is load-bearing --
|
|
119
|
+
it is what makes a complete row distinguishable from a truncated one.
|
|
120
|
+
|
|
121
|
+
ponytail: no locking. O_APPEND is the lock. Add one only if this ever needs
|
|
122
|
+
to write multi-line entries, which would break the single-write property.
|
|
123
|
+
"""
|
|
124
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
125
|
+
if parent:
|
|
126
|
+
os.makedirs(parent, exist_ok=True)
|
|
127
|
+
line = json.dumps(entry, sort_keys=True) + "\n"
|
|
128
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
129
|
+
handle.write(line)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load(path):
|
|
133
|
+
"""Read the history. Returns (entries, corrupt_count).
|
|
134
|
+
|
|
135
|
+
A line that is not JSON, or is JSON but not an object, is corrupt and
|
|
136
|
+
COUNTED. It is never dropped on the floor.
|
|
137
|
+
"""
|
|
138
|
+
entries, corrupt = [], 0
|
|
139
|
+
try:
|
|
140
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
141
|
+
raw = handle.read()
|
|
142
|
+
except OSError:
|
|
143
|
+
return None, 0
|
|
144
|
+
for line in raw.splitlines():
|
|
145
|
+
if not line.strip():
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
obj = json.loads(line)
|
|
149
|
+
except ValueError:
|
|
150
|
+
corrupt += 1
|
|
151
|
+
continue
|
|
152
|
+
if not isinstance(obj, dict) or "usd" not in obj:
|
|
153
|
+
corrupt += 1
|
|
154
|
+
continue
|
|
155
|
+
entries.append(obj)
|
|
156
|
+
return entries, corrupt
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def median(values):
|
|
160
|
+
"""Median of a non-empty list. Even length averages the middle pair."""
|
|
161
|
+
s = sorted(values)
|
|
162
|
+
n = len(s)
|
|
163
|
+
mid = n // 2
|
|
164
|
+
return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def direction(values):
|
|
168
|
+
"""(label, explanation) for a series oldest to newest.
|
|
169
|
+
|
|
170
|
+
Compares the median of the older half against the median of the newer
|
|
171
|
+
half, not first-vs-last: a single expensive outlier at either end should
|
|
172
|
+
not name the trend for a whole quarter. With 2 or 3 points the halves are
|
|
173
|
+
small, so the comparison it used is always printed alongside the verdict --
|
|
174
|
+
the operator can see how thin the evidence is.
|
|
175
|
+
"""
|
|
176
|
+
if len(values) < 2:
|
|
177
|
+
return "INSUFFICIENT DATA", (
|
|
178
|
+
"%d measured run(s); a trend needs at least 2. One observation "
|
|
179
|
+
"cannot show change over time, and 'flat' would be a claim."
|
|
180
|
+
% len(values))
|
|
181
|
+
half = len(values) // 2
|
|
182
|
+
older = median(values[:half])
|
|
183
|
+
newer = median(values[len(values) - half:])
|
|
184
|
+
basis = ("median of the oldest %d ($%.4f) vs the newest %d ($%.4f)"
|
|
185
|
+
% (half, older, half, newer))
|
|
186
|
+
if older == 0:
|
|
187
|
+
if newer == 0:
|
|
188
|
+
return "flat", basis + "; both zero"
|
|
189
|
+
return "rising", basis + "; percent change is undefined against zero"
|
|
190
|
+
pct = (newer - older) / older * 100.0
|
|
191
|
+
if abs(pct) < FLAT_PCT:
|
|
192
|
+
return "flat", "%s; %+.1f%%, within the %.0f%% flat band" % (
|
|
193
|
+
basis, pct, FLAT_PCT)
|
|
194
|
+
return ("rising" if pct > 0 else "falling"), "%s; %+.1f%%" % (basis, pct)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def record(workspace, path, now=None):
|
|
198
|
+
"""Append this workspace's run to the history. Returns a verdict dict."""
|
|
199
|
+
cost, model = collect_efficiency(_loki_dir(workspace))
|
|
200
|
+
usd = measured_usd(cost)
|
|
201
|
+
entry = {
|
|
202
|
+
"ts": now if now is not None else time.time(),
|
|
203
|
+
"workspace": os.path.abspath(workspace),
|
|
204
|
+
"usd": usd, # None when unmeasured. NEVER 0 as a stand-in.
|
|
205
|
+
"measured": usd is not None,
|
|
206
|
+
"model": model or None,
|
|
207
|
+
}
|
|
208
|
+
try:
|
|
209
|
+
_append(path, entry)
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return {"status": "cannot_record", "exit_code": CANNOT,
|
|
212
|
+
"why": "could not append to %s: %s" % (path, exc),
|
|
213
|
+
"entry": None}
|
|
214
|
+
return {"status": "recorded", "exit_code": OK, "why": None,
|
|
215
|
+
"entry": entry}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def report(path):
|
|
219
|
+
"""Summarize the history. Returns a verdict dict."""
|
|
220
|
+
entries, corrupt = load(path)
|
|
221
|
+
if entries is None:
|
|
222
|
+
return {"status": "no_history", "exit_code": NOTHING,
|
|
223
|
+
"why": "no history file at %s; record a run first." % path,
|
|
224
|
+
"runs": 0, "measured": 0, "corrupt_lines": 0,
|
|
225
|
+
"costs": [], "median_usd": None,
|
|
226
|
+
"direction": "INSUFFICIENT DATA", "basis": None}
|
|
227
|
+
|
|
228
|
+
costs = [e["usd"] for e in entries
|
|
229
|
+
if e.get("measured") and _num(e.get("usd")) is not None]
|
|
230
|
+
|
|
231
|
+
if not entries:
|
|
232
|
+
# An empty file is not a flat trend. Zero runs is an absent
|
|
233
|
+
# measurement, and reporting stability from it would be a claim made
|
|
234
|
+
# out of nothing.
|
|
235
|
+
return {"status": "empty", "exit_code": NOTHING,
|
|
236
|
+
"why": "history %s is empty (%d corrupt line(s)); zero runs "
|
|
237
|
+
"is not a flat trend." % (path, corrupt),
|
|
238
|
+
"runs": 0, "measured": 0, "corrupt_lines": corrupt,
|
|
239
|
+
"costs": [], "median_usd": None,
|
|
240
|
+
"direction": "INSUFFICIENT DATA", "basis": None}
|
|
241
|
+
|
|
242
|
+
label, basis = direction(costs)
|
|
243
|
+
return {
|
|
244
|
+
"status": "ok" if costs else "no_measured_runs",
|
|
245
|
+
"exit_code": OK if costs else NOTHING,
|
|
246
|
+
"why": None if costs else (
|
|
247
|
+
"%d run(s) recorded but none carried a measured cost; there is "
|
|
248
|
+
"nothing to trend. Unmeasured runs are kept as null, not 0."
|
|
249
|
+
% len(entries)),
|
|
250
|
+
"runs": len(entries),
|
|
251
|
+
"measured": len(costs),
|
|
252
|
+
"corrupt_lines": corrupt,
|
|
253
|
+
"costs": costs,
|
|
254
|
+
"median_usd": median(costs) if costs else None,
|
|
255
|
+
"direction": label,
|
|
256
|
+
"basis": basis,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render(d):
|
|
261
|
+
if d["status"] in ("no_history", "empty"):
|
|
262
|
+
return "NO TREND: %s" % d["why"]
|
|
263
|
+
if d["status"] == "cannot_record":
|
|
264
|
+
return "CANNOT RECORD: %s" % d["why"]
|
|
265
|
+
if d["status"] == "recorded":
|
|
266
|
+
e = d["entry"]
|
|
267
|
+
return ("recorded %s: %s" % (
|
|
268
|
+
e["workspace"],
|
|
269
|
+
"$%.4f" % e["usd"] if e["measured"]
|
|
270
|
+
else "UNMEASURED (stored as null, excluded from the trend)"))
|
|
271
|
+
|
|
272
|
+
lines = ["%d run(s), %d measured" % (d["runs"], d["measured"])]
|
|
273
|
+
if d["corrupt_lines"]:
|
|
274
|
+
# Named, not swallowed. A dropped row makes the trend look cleaner
|
|
275
|
+
# than reality.
|
|
276
|
+
lines.append("%d CORRUPT line(s) in the history -- counted, not "
|
|
277
|
+
"skipped; the trend below omits them."
|
|
278
|
+
% d["corrupt_lines"])
|
|
279
|
+
if d["status"] == "no_measured_runs":
|
|
280
|
+
lines.append("NO TREND: %s" % d["why"])
|
|
281
|
+
return "\n".join(lines)
|
|
282
|
+
lines.append("costs oldest to newest: "
|
|
283
|
+
+ ", ".join("$%.4f" % c for c in d["costs"]))
|
|
284
|
+
lines.append("median: $%.4f" % d["median_usd"])
|
|
285
|
+
lines.append("direction: %s (%s)" % (d["direction"], d["basis"]))
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main(argv=None):
|
|
290
|
+
ap = argparse.ArgumentParser(
|
|
291
|
+
description="Track agent cost across many runs and report the trend.")
|
|
292
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
293
|
+
|
|
294
|
+
rec = sub.add_parser("record", help="append this run's cost to the history")
|
|
295
|
+
rec.add_argument("workspace", nargs="?", default=".",
|
|
296
|
+
help="workspace root (or its .loki dir); default .")
|
|
297
|
+
rec.add_argument("--file", default=DEFAULT_FILE,
|
|
298
|
+
help="history JSONL (default %s)" % DEFAULT_FILE)
|
|
299
|
+
|
|
300
|
+
rep = sub.add_parser("report", help="summarize the recorded history")
|
|
301
|
+
rep.add_argument("--file", default=DEFAULT_FILE,
|
|
302
|
+
help="history JSONL (default %s)" % DEFAULT_FILE)
|
|
303
|
+
rep.add_argument("--json", action="store_true", dest="as_json",
|
|
304
|
+
help="emit the report as JSON")
|
|
305
|
+
|
|
306
|
+
args = ap.parse_args(argv)
|
|
307
|
+
if args.cmd == "record":
|
|
308
|
+
d = record(args.workspace, args.file)
|
|
309
|
+
print(render(d))
|
|
310
|
+
else:
|
|
311
|
+
d = report(args.file)
|
|
312
|
+
print(json.dumps(d, indent=2) if args.as_json else render(d))
|
|
313
|
+
return d["exit_code"]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sys.exit(main())
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a ci-gate verdict as CI-native output. Never invent a verdict.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. tools/ci-gate.py already decides correctly and exits 0/1/2.
|
|
5
|
+
But in a CI run its text table lands in a collapsed log that nobody opens. The
|
|
6
|
+
two surfaces an engineer actually reads are the run page ($GITHUB_STEP_SUMMARY,
|
|
7
|
+
markdown) and the PR diff annotations (::error / ::warning workflow commands).
|
|
8
|
+
Nothing carried our verdict to either, so the gate's most important state --
|
|
9
|
+
"this policy could not be checked" -- was invisible at exactly the moment it
|
|
10
|
+
mattered.
|
|
11
|
+
|
|
12
|
+
python3 tools/ci-gate.py <ws> --max-usd 5 --json \
|
|
13
|
+
| python3 tools/gate-report.py --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
14
|
+
|
|
15
|
+
THE RULE THIS FILE EXISTS TO HOLD, inherited from ci-gate.py:
|
|
16
|
+
|
|
17
|
+
A POLICY THAT COULD NOT BE EVALUATED HAS NOT PASSED.
|
|
18
|
+
|
|
19
|
+
A renderer is where that rule dies quietly. A table styles UNEVALUABLE like a
|
|
20
|
+
minor note; an annotation layer emits ::error for failures and simply skips
|
|
21
|
+
everything else. Both produce a run page with no red on it, and a reviewer
|
|
22
|
+
reads absence-of-red as pass. So UNEVALUABLE renders exactly as prominently as
|
|
23
|
+
FAIL, and in the github format it is a ::warning at minimum -- never silent,
|
|
24
|
+
never ::notice. There is one severity map, below, and both formats read it.
|
|
25
|
+
|
|
26
|
+
WHY THE EXIT CODE IS RE-EMITTED. In `ci-gate.py ... | gate-report.py` the shell
|
|
27
|
+
keeps ONLY the last command's status. ci-gate's 2 is discarded by the pipe. If
|
|
28
|
+
this file exited 0 for a rendered blind gate, adding a human-readable report
|
|
29
|
+
would have converted a blocked merge into a green check -- the pipeline would
|
|
30
|
+
be made LESS safe by the act of describing itself. So the input's own exit_code
|
|
31
|
+
is re-emitted, and anything that is not a recognised verdict exits 2.
|
|
32
|
+
|
|
33
|
+
WHAT IT REFUSES TO DO. It never computes a state, only relays one. A row with
|
|
34
|
+
no state, or a state word this file does not recognise, is UNEVALUABLE -- the
|
|
35
|
+
input did not carry a verdict, and a renderer that fills that gap with "pass"
|
|
36
|
+
is manufacturing evidence. Empty stdin and malformed JSON are errors on stderr
|
|
37
|
+
with exit 2, never an empty report: a report of nothing is indistinguishable
|
|
38
|
+
from a clean run.
|
|
39
|
+
|
|
40
|
+
Exit: mirrors the input verdict (0 pass, 1 failed, 2 unevaluable); 2 on any
|
|
41
|
+
input this file could not parse.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
PASS, FAIL, UNEVALUABLE = 0, 1, 2
|
|
49
|
+
|
|
50
|
+
# THE ONE MAPPING. Both formats read it, so a change of severity cannot apply
|
|
51
|
+
# to one surface and not the other. "notice" is reserved for PASS: an
|
|
52
|
+
# unevaluable policy demoted to a notice is precisely the silent-green failure
|
|
53
|
+
# this tool exists to prevent.
|
|
54
|
+
_SEVERITY = {"PASS": "notice", "FAIL": "error", "UNEVALUABLE": "warning"}
|
|
55
|
+
|
|
56
|
+
_EXIT = {"PASS": PASS, "FAIL": FAIL, "UNEVALUABLE": UNEVALUABLE}
|
|
57
|
+
|
|
58
|
+
# ponytail: markdown emphasis is derived, not a second table to drift.
|
|
59
|
+
_UNKNOWN = ("UNEVALUABLE", "state not reported by the gate: the input carried "
|
|
60
|
+
"no recognised verdict for this policy, so it has not passed")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _state_of(row):
|
|
64
|
+
"""The row's own verdict, or UNEVALUABLE. Never a default of PASS."""
|
|
65
|
+
if not isinstance(row, dict):
|
|
66
|
+
return _UNKNOWN[0], "malformed policy entry: %r" % (row,), "?"
|
|
67
|
+
state = row.get("state")
|
|
68
|
+
policy = str(row.get("policy") or "?")
|
|
69
|
+
reason = row.get("reason")
|
|
70
|
+
reason = reason.strip() if isinstance(reason, str) and reason.strip() \
|
|
71
|
+
else "no detail reported"
|
|
72
|
+
if not isinstance(state, str) or state.upper() not in _SEVERITY:
|
|
73
|
+
return _UNKNOWN[0], "%s (was %r)" % (_UNKNOWN[1], state), policy
|
|
74
|
+
return state.upper(), reason, policy
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _rows(verdict):
|
|
78
|
+
"""Every policy row, plus a synthetic row when the gate carried none.
|
|
79
|
+
|
|
80
|
+
An empty policies list with a top-level UNEVALUABLE is ci-gate's
|
|
81
|
+
"no policy configured" verdict. Rendering an empty table there would draw a
|
|
82
|
+
header and no rows, which reads as a clean run -- the exact inversion this
|
|
83
|
+
file exists to stop.
|
|
84
|
+
"""
|
|
85
|
+
policies = verdict.get("policies")
|
|
86
|
+
out = []
|
|
87
|
+
if isinstance(policies, list):
|
|
88
|
+
for row in policies:
|
|
89
|
+
out.append(_state_of(row))
|
|
90
|
+
if out:
|
|
91
|
+
return out
|
|
92
|
+
# No policy results arrived. Whatever the header claims, nothing was
|
|
93
|
+
# checked, so this row is UNEVALUABLE unconditionally -- trusting a
|
|
94
|
+
# top-level "PASS" here would render a green table built from zero
|
|
95
|
+
# evidence, which is the one thing this file must never do.
|
|
96
|
+
return [(_UNKNOWN[0], _overall_reason(verdict), "(none)")]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _overall_reason(verdict):
|
|
100
|
+
reason = verdict.get("reason")
|
|
101
|
+
if isinstance(reason, str) and reason.strip():
|
|
102
|
+
return reason.strip()
|
|
103
|
+
return "the gate reported no policy results and no reason"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _verdict_state(verdict):
|
|
107
|
+
"""The overall state, never weaker than the worst row.
|
|
108
|
+
|
|
109
|
+
The header is a claim; the rows are the evidence. A body asserting PASS
|
|
110
|
+
over a FAILED or unreported row is not a pass, so the summary line is
|
|
111
|
+
reconciled against the rows here -- the same weakest-link rule ci-gate
|
|
112
|
+
applies, and the same one exit_code() applies. Reporting a green summary
|
|
113
|
+
above a red table is how a reader ends up trusting the wrong one.
|
|
114
|
+
"""
|
|
115
|
+
state = verdict.get("state")
|
|
116
|
+
claimed = state.upper() if isinstance(state, str) \
|
|
117
|
+
and state.upper() in _SEVERITY else _UNKNOWN[0]
|
|
118
|
+
worst = max([_EXIT[claimed]] +
|
|
119
|
+
[_EXIT[row[0]] for row in _rows(verdict)])
|
|
120
|
+
return {v: k for k, v in _EXIT.items()}[worst]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_markdown(verdict):
|
|
124
|
+
state = _verdict_state(verdict)
|
|
125
|
+
lines = ["## Merge gate: %s" % _headline(state), "",
|
|
126
|
+
"| Policy | State | Detail |", "| --- | --- | --- |"]
|
|
127
|
+
for row_state, reason, policy in _rows(verdict):
|
|
128
|
+
# Bold every non-pass. An unevaluable row must not be visually quieter
|
|
129
|
+
# than a failed one on the run page.
|
|
130
|
+
cell = row_state if row_state == "PASS" else "**%s**" % row_state
|
|
131
|
+
lines.append("| %s | %s | %s |" % (policy, cell, _cell(reason)))
|
|
132
|
+
lines.append("")
|
|
133
|
+
lines.append("%s -- %s" % (_headline(state), _overall_reason(verdict)))
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _headline(state):
|
|
138
|
+
if state == "PASS":
|
|
139
|
+
return "PASS"
|
|
140
|
+
if state == "FAIL":
|
|
141
|
+
return "FAIL"
|
|
142
|
+
return "UNEVALUABLE (could not be checked -- this is not a pass)"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cell(text):
|
|
146
|
+
# A pipe in a reason would silently split the row into extra columns.
|
|
147
|
+
return " ".join(text.split()).replace("|", "\\|")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_github(verdict, file_hint=None):
|
|
151
|
+
"""Workflow commands. Non-pass rows are never silent."""
|
|
152
|
+
lines = []
|
|
153
|
+
loc = "file=%s," % file_hint if file_hint else ""
|
|
154
|
+
for row_state, reason, policy in _rows(verdict):
|
|
155
|
+
lines.append("::%s %stitle=gate: %s (%s)::%s"
|
|
156
|
+
% (_SEVERITY[row_state], loc, policy, row_state,
|
|
157
|
+
_annotation_text(reason)))
|
|
158
|
+
state = _verdict_state(verdict)
|
|
159
|
+
lines.append("::%s %stitle=merge gate %s::%s"
|
|
160
|
+
% (_SEVERITY[state], loc, state,
|
|
161
|
+
_annotation_text(_overall_reason(verdict))))
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _one_line(text):
|
|
166
|
+
# A raw newline would terminate the workflow command mid-message and leave
|
|
167
|
+
# the tail rendered as plain log noise.
|
|
168
|
+
return " ".join(text.split())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _annotation_text(text):
|
|
172
|
+
# % opens an escape sequence in a workflow-command message, and
|
|
173
|
+
# cost-guard's --max-increase-pct reasons carry literal percentages. Only
|
|
174
|
+
# the github format needs this; plain text must keep its real % sign.
|
|
175
|
+
return _one_line(text).replace("%", "%25")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_text(verdict):
|
|
179
|
+
lines = ["%-10s %-12s %s" % ("POLICY", "STATE", "DETAIL")]
|
|
180
|
+
for row_state, reason, policy in _rows(verdict):
|
|
181
|
+
lines.append("%-10s %-12s %s" % (policy, row_state, _one_line(reason)))
|
|
182
|
+
lines.append("")
|
|
183
|
+
lines.append("GATE: %s -- %s" % (_headline(_verdict_state(verdict)),
|
|
184
|
+
_overall_reason(verdict)))
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_FORMATS = {"markdown": render_markdown, "github": render_github,
|
|
189
|
+
"text": render_text}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def exit_code(verdict):
|
|
193
|
+
"""The input's own exit semantics. A pipe drops them; this restores them."""
|
|
194
|
+
code = verdict.get("exit_code")
|
|
195
|
+
if isinstance(code, bool) or not isinstance(code, int) \
|
|
196
|
+
or code not in (PASS, FAIL, UNEVALUABLE):
|
|
197
|
+
# No trustworthy verdict came in, so none goes out. 2 matches
|
|
198
|
+
# ci-gate's own vocabulary: to a CI job, "blind" and "broken" are the
|
|
199
|
+
# same fact and neither may merge.
|
|
200
|
+
return UNEVALUABLE
|
|
201
|
+
# Cross-check: a body claiming PASS while carrying a non-pass row is not a
|
|
202
|
+
# pass. Weakest link, same rule ci-gate uses, applied to what arrived --
|
|
203
|
+
# and the same reconciliation the rendered summary shows, so the exit code
|
|
204
|
+
# can never disagree with the report a human just read.
|
|
205
|
+
return max(code, _EXIT[_verdict_state(verdict)])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv=None):
|
|
209
|
+
ap = argparse.ArgumentParser(
|
|
210
|
+
description="Render a ci-gate JSON verdict as CI-native output.")
|
|
211
|
+
ap.add_argument("--format", choices=sorted(_FORMATS), default="markdown",
|
|
212
|
+
help="markdown (step summary), github (annotations), text")
|
|
213
|
+
ap.add_argument("--file", dest="file_hint",
|
|
214
|
+
help="path to attach to github annotations; omitted when "
|
|
215
|
+
"not given, because an annotation on the wrong file "
|
|
216
|
+
"is worse than none")
|
|
217
|
+
args = ap.parse_args(argv)
|
|
218
|
+
|
|
219
|
+
raw = sys.stdin.read()
|
|
220
|
+
if not raw.strip():
|
|
221
|
+
# A report of nothing looks exactly like a clean run.
|
|
222
|
+
sys.stderr.write("gate-report: empty stdin -- expected ci-gate JSON on "
|
|
223
|
+
"stdin. Nothing to report is not a pass.\n")
|
|
224
|
+
return UNEVALUABLE
|
|
225
|
+
try:
|
|
226
|
+
verdict = json.loads(raw)
|
|
227
|
+
except ValueError as exc:
|
|
228
|
+
sys.stderr.write("gate-report: could not parse the gate verdict: %s\n"
|
|
229
|
+
% exc)
|
|
230
|
+
return UNEVALUABLE
|
|
231
|
+
if not isinstance(verdict, dict):
|
|
232
|
+
sys.stderr.write("gate-report: expected a JSON object from ci-gate, "
|
|
233
|
+
"got %s\n" % type(verdict).__name__)
|
|
234
|
+
return UNEVALUABLE
|
|
235
|
+
|
|
236
|
+
# Render fully before printing: a malformed body must never emit a partial
|
|
237
|
+
# report that a reader mistakes for the whole verdict.
|
|
238
|
+
if args.format == "github":
|
|
239
|
+
out = render_github(verdict, args.file_hint)
|
|
240
|
+
else:
|
|
241
|
+
out = _FORMATS[args.format](verdict)
|
|
242
|
+
code = exit_code(verdict)
|
|
243
|
+
print(out)
|
|
244
|
+
return code
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
sys.exit(main())
|
package/tools/model-advisor.py
CHANGED
|
@@ -484,10 +484,21 @@ def main(argv=None):
|
|
|
484
484
|
ap.add_argument("--json", action="store_true")
|
|
485
485
|
args = ap.parse_args(argv)
|
|
486
486
|
|
|
487
|
+
# A workspace that does not EXIST is not the same fact as a workspace with
|
|
488
|
+
# no cost history, and exit 0 collapsed them. A CI job doing
|
|
489
|
+
# `model-advisor.py "$WS" && ...` on a mistyped or unmounted path saw green
|
|
490
|
+
# and carried on. Every sibling tool distinguishes these (run-replay 66,
|
|
491
|
+
# cost-guard 2, receipt-bundle 3); this one did not.
|
|
492
|
+
if not os.path.isdir(args.workspace):
|
|
493
|
+
sys.stderr.write(
|
|
494
|
+
"cannot advise: workspace does not exist: %s\n" % args.workspace)
|
|
495
|
+
return 66
|
|
496
|
+
|
|
487
497
|
adv = advise(args.workspace)
|
|
488
498
|
print(json.dumps(adv, indent=2) if args.json else render(adv))
|
|
489
|
-
# Exit 0
|
|
490
|
-
# failure
|
|
499
|
+
# Exit 0 for a real workspace with no basis: that IS a successful, honest
|
|
500
|
+
# answer rather than a tool failure, and the output says so in words.
|
|
501
|
+
# Callers read has_basis.
|
|
491
502
|
return 0
|
|
492
503
|
|
|
493
504
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Merge policy as a version-controlled FILE, not a CI flag nobody reviews.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. tools/ci-gate.py takes its policy as CLI flags: --max-usd,
|
|
5
|
+
--require-receipt. That works exactly once, in one job, on one machine. The
|
|
6
|
+
ceiling lives in YAML nobody diffs, it cannot be reviewed in a PR, and two
|
|
7
|
+
repos in the same org drift apart with nobody able to name the moment it
|
|
8
|
+
happened. Lowering a ceiling should look like a code change, because it is one.
|
|
9
|
+
|
|
10
|
+
So: put the policy in a file, load it here, and hand ci-gate its flags.
|
|
11
|
+
|
|
12
|
+
python3 tools/ci-gate.py $(python3 tools/policy-load.py --as-args)
|
|
13
|
+
|
|
14
|
+
THAT COMPOSITION IS WHY EVERY FAILURE HERE IS LOUD. This tool's stdout becomes
|
|
15
|
+
another gate's argv. A loader that shrugs at bad input does not produce a weak
|
|
16
|
+
policy, it produces a gate invoked with NOTHING TO ENFORCE, which ci-gate's own
|
|
17
|
+
docstring calls a vacuously-green gate: worse than no gate, because it is
|
|
18
|
+
trusted. This repo has paid for that shape four times (four detectors missing
|
|
19
|
+
from the package, a deferred dist check, a tarball assertion passing on "6 or
|
|
20
|
+
more" of 6). Every one was a check reporting a pass without having checked.
|
|
21
|
+
|
|
22
|
+
Hence four rules, each of which is one way to be silently unenforced:
|
|
23
|
+
|
|
24
|
+
UNKNOWN KEY IS AN ERROR. "max_usd_" is not "max_usd". Skipping it leaves an
|
|
25
|
+
operator certain a ceiling is enforced while nothing is. The misspelling is
|
|
26
|
+
invisible precisely because the file still looks like a policy. Named, not
|
|
27
|
+
counted: "1 unknown key" does not tell you which line to fix.
|
|
28
|
+
|
|
29
|
+
EMPTY OR ABSENT IS AN ERROR, not an empty policy. An empty policy enforces
|
|
30
|
+
nothing while looking configured. And "implies no flags" is the same hole one
|
|
31
|
+
level up: {"require_receipt": false} is valid JSON, has a key, and expands to
|
|
32
|
+
an empty argv. So the test is what the policy ENFORCES, not what it contains.
|
|
33
|
+
|
|
34
|
+
VALUES ARE VALIDATED, NOT JUST KEYS. A ceiling of -1 passes every run. So
|
|
35
|
+
does NaN, which json.loads accepts as a bare literal and which loses every
|
|
36
|
+
comparison it appears in -- an unenforceable ceiling that reads as a number.
|
|
37
|
+
So does True, which is an int in Python and floats to 1.0.
|
|
38
|
+
|
|
39
|
+
MALFORMED JSON REPORTS THE PARSE ERROR AND THE PATH, never a partial policy.
|
|
40
|
+
|
|
41
|
+
Exit 0 only when a non-empty, fully-valid policy loaded and implies at least
|
|
42
|
+
one flag. Diagnostics go to stderr, always: anything on stdout gets word-split
|
|
43
|
+
straight into ci-gate's argv.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
tools/policy-load.py [--file .loki-policy.json] [--json] [--as-args]
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import argparse
|
|
50
|
+
import json
|
|
51
|
+
import math
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
DEFAULT_FILE = ".loki-policy.json"
|
|
55
|
+
|
|
56
|
+
# The entire schema. Two keys, because ci-gate enforces exactly two policies.
|
|
57
|
+
# `workspace` is per-invocation and not a policy (and a path with a space would
|
|
58
|
+
# silently split under $(...)); `json` is an output format. Neither belongs here.
|
|
59
|
+
KNOWN_KEYS = ("max_usd", "require_receipt")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PolicyError(Exception):
|
|
63
|
+
"""A policy that must not be handed to a gate."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_max_usd(value):
|
|
67
|
+
# bool is a subclass of int: `true` would otherwise become a $1.00 ceiling.
|
|
68
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
69
|
+
return "max_usd must be a number, got {!r}".format(value)
|
|
70
|
+
# NaN and Infinity are bare literals json.loads accepts. NaN loses every
|
|
71
|
+
# comparison, so a NaN ceiling never trips while looking like a number.
|
|
72
|
+
# isfinite raises OverflowError on a 400-digit int literal; that already
|
|
73
|
+
# fails closed, but a traceback is a worse message than a named reason.
|
|
74
|
+
try:
|
|
75
|
+
finite = math.isfinite(value)
|
|
76
|
+
except OverflowError:
|
|
77
|
+
finite = False
|
|
78
|
+
if not finite:
|
|
79
|
+
return "max_usd must be a finite number, got {!r}".format(value)
|
|
80
|
+
if value < 0:
|
|
81
|
+
return "max_usd must not be negative, got {!r}".format(value)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_require_receipt(value):
|
|
86
|
+
# "true" is a string and is truthy; it is not a boolean policy.
|
|
87
|
+
if not isinstance(value, bool):
|
|
88
|
+
return "require_receipt must be true or false, got {!r}".format(value)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
VALIDATORS = {
|
|
93
|
+
"max_usd": _check_max_usd,
|
|
94
|
+
"require_receipt": _check_require_receipt,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load(path):
|
|
99
|
+
"""Return a validated policy dict, or raise PolicyError naming the problem."""
|
|
100
|
+
try:
|
|
101
|
+
with open(path, encoding="utf-8") as handle:
|
|
102
|
+
raw = handle.read()
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
raise PolicyError(
|
|
105
|
+
"no policy file at {}: a gate with no policy enforces nothing".format(path))
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise PolicyError("cannot read {}: {}".format(path, exc))
|
|
108
|
+
|
|
109
|
+
if not raw.strip():
|
|
110
|
+
raise PolicyError(
|
|
111
|
+
"policy file is empty: {} -- an empty policy enforces nothing "
|
|
112
|
+
"while looking configured".format(path))
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
policy = json.loads(raw)
|
|
116
|
+
except json.JSONDecodeError as exc:
|
|
117
|
+
raise PolicyError("malformed JSON in {}: {}".format(path, exc))
|
|
118
|
+
|
|
119
|
+
if not isinstance(policy, dict):
|
|
120
|
+
raise PolicyError(
|
|
121
|
+
"policy in {} must be a JSON object, got {}".format(
|
|
122
|
+
path, type(policy).__name__))
|
|
123
|
+
|
|
124
|
+
# Named, sorted, and ALL of them: fixing one misspelling at a time across
|
|
125
|
+
# three re-runs is how the third one gets abandoned.
|
|
126
|
+
unknown = sorted(k for k in policy if k not in KNOWN_KEYS)
|
|
127
|
+
if unknown:
|
|
128
|
+
raise PolicyError(
|
|
129
|
+
"unknown policy key(s) in {}: {} -- known keys are: {}".format(
|
|
130
|
+
path, ", ".join(unknown), ", ".join(KNOWN_KEYS)))
|
|
131
|
+
|
|
132
|
+
# Iterate KNOWN_KEYS, not policy.items(): indexing VALIDATORS by a key
|
|
133
|
+
# straight from the file makes an unknown key a KeyError traceback instead
|
|
134
|
+
# of the named message above. That still exits non-zero, so the check looks
|
|
135
|
+
# tested while the operator gets a stack trace naming only the first typo.
|
|
136
|
+
problems = [msg for key in KNOWN_KEYS if key in policy
|
|
137
|
+
for msg in [VALIDATORS[key](policy[key])] if msg]
|
|
138
|
+
if problems:
|
|
139
|
+
raise PolicyError("invalid policy in {}: {}".format(path, "; ".join(problems)))
|
|
140
|
+
|
|
141
|
+
if not policy:
|
|
142
|
+
raise PolicyError(
|
|
143
|
+
"policy in {} is empty: a gate driven by an empty policy enforces "
|
|
144
|
+
"nothing while looking configured".format(path))
|
|
145
|
+
|
|
146
|
+
return policy
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def as_args(policy):
|
|
150
|
+
"""The ci-gate flags this policy implies."""
|
|
151
|
+
args = []
|
|
152
|
+
if "max_usd" in policy:
|
|
153
|
+
args += ["--max-usd", repr(float(policy["max_usd"]))]
|
|
154
|
+
if policy.get("require_receipt"):
|
|
155
|
+
args.append("--require-receipt")
|
|
156
|
+
return args
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv=None):
|
|
160
|
+
ap = argparse.ArgumentParser(
|
|
161
|
+
description="Load and validate a merge policy file for ci-gate.py.")
|
|
162
|
+
ap.add_argument("--file", default=DEFAULT_FILE,
|
|
163
|
+
help="policy file to load; default {}".format(DEFAULT_FILE))
|
|
164
|
+
ap.add_argument("--json", action="store_true", dest="as_json",
|
|
165
|
+
help="emit the validated policy as JSON")
|
|
166
|
+
ap.add_argument("--as-args", action="store_true", dest="args_only",
|
|
167
|
+
help="emit the ci-gate flags this policy implies")
|
|
168
|
+
args = ap.parse_args(argv)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
policy = load(args.file)
|
|
172
|
+
flags = as_args(policy)
|
|
173
|
+
# require_receipt:false is valid, non-empty, and enforces NOTHING. Let
|
|
174
|
+
# it exit 0 and `ci-gate $(...)` runs with an empty argv -- the same
|
|
175
|
+
# vacuously-green gate this file exists to prevent, one level up.
|
|
176
|
+
if not flags:
|
|
177
|
+
raise PolicyError(
|
|
178
|
+
"policy in {} enforces nothing: it implies no ci-gate flags, so "
|
|
179
|
+
"the gate would run with nothing to check".format(args.file))
|
|
180
|
+
except PolicyError as exc:
|
|
181
|
+
print("policy-load: {}".format(exc), file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
if args.args_only:
|
|
185
|
+
print(" ".join(flags))
|
|
186
|
+
elif args.as_json:
|
|
187
|
+
print(json.dumps(policy, indent=2, sort_keys=True))
|
|
188
|
+
else:
|
|
189
|
+
for key in sorted(policy):
|
|
190
|
+
print("{} = {}".format(key, json.dumps(policy[key])))
|
|
191
|
+
print("ci-gate args: {}".format(" ".join(flags)))
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
sys.exit(main())
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Can this machine produce SIGNED receipts? Prove it, do not assume it.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. An unsigned Evidence Receipt proves INTEGRITY but not ORIGIN.
|
|
5
|
+
The hash says the bytes were not tampered with; it says nothing about who
|
|
6
|
+
produced them, and anyone can generate a receipt with a valid hash. Origin is
|
|
7
|
+
the part a third party is actually buying. GPG signing closes that gap and has
|
|
8
|
+
shipped since the proof generator learned LOKI_PROOF_GPG_KEY -- default OFF,
|
|
9
|
+
undiscoverable, and silent when broken.
|
|
10
|
+
|
|
11
|
+
Silent is the dangerous word. autonomy/lib/proof-generator.py:_gpg_detached_sign
|
|
12
|
+
swallows EVERY failure and returns None: missing key, expired key, revoked key,
|
|
13
|
+
a passphrase this environment cannot supply. Signing is best-effort by design so
|
|
14
|
+
a gpg problem never blocks proof emission. The cost of that design is that a
|
|
15
|
+
user who set LOKI_PROOF_GPG_KEY, believes receipts are signed, and has a broken
|
|
16
|
+
key gets UNSIGNED receipts forever with no error anywhere. This tool is the only
|
|
17
|
+
place that failure becomes visible.
|
|
18
|
+
|
|
19
|
+
So the bar here is higher than "gpg is installed and a key id is set". That
|
|
20
|
+
sentence is compatible with every one of the failures above. The only honest
|
|
21
|
+
evidence that this machine can sign is a real detached-sign of a real payload
|
|
22
|
+
followed by a real verify of the resulting signature -- with the SAME argv the
|
|
23
|
+
receipt pipeline uses, or the round trip predicts nothing about the pipeline.
|
|
24
|
+
|
|
25
|
+
FOUR STATES, NEVER A BOOLEAN. Collapsing these sends a user to debug the wrong
|
|
26
|
+
thing, which for a security control is worse than saying nothing:
|
|
27
|
+
|
|
28
|
+
ok round trip signed AND verified. Receipts carry origin.
|
|
29
|
+
broken configured, and signing FAILED. Receipts silently unsigned.
|
|
30
|
+
not_configured gpg works, LOKI_PROOF_GPG_KEY unset. Nothing is wrong.
|
|
31
|
+
gpg_absent no gpg on PATH. Signing is impossible until installed.
|
|
32
|
+
|
|
33
|
+
KEY MATERIAL NEVER LEAVES. A key ID is an identifier and is safe to print.
|
|
34
|
+
Secret key bytes and passphrases are not, and gpg writes diagnostics to stderr
|
|
35
|
+
that can quote them. Raw gpg stderr is therefore NEVER printed: it is
|
|
36
|
+
classified into a known reason and only the classification is emitted. No
|
|
37
|
+
--export path is invoked anywhere in this file. Requirement and implementation
|
|
38
|
+
are the same line: print the reason, not the transcript.
|
|
39
|
+
|
|
40
|
+
Read-only against the keyring: no key is created, deleted, imported, or
|
|
41
|
+
modified. The round trip signs a scratch file in a temp dir that is removed
|
|
42
|
+
afterwards, and uses the caller's real GNUPGHOME because the question being
|
|
43
|
+
answered is whether the REAL keyring can sign.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import shutil
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
import tempfile
|
|
53
|
+
|
|
54
|
+
# The payload is arbitrary: the round trip proves the KEY works, not that any
|
|
55
|
+
# particular bytes are special. Fixed and tiny so a signature is fast.
|
|
56
|
+
_SCRATCH_PAYLOAD = b"loki-signing-status round-trip probe\n"
|
|
57
|
+
|
|
58
|
+
_TIMEOUT = 30
|
|
59
|
+
|
|
60
|
+
# Ordered: the first match wins, so the more specific pattern precedes the
|
|
61
|
+
# general one. Left side is matched against LOWERCASED gpg stderr; right side is
|
|
62
|
+
# what the user is told. Never the raw stderr -- see the module docstring.
|
|
63
|
+
_REASONS = (
|
|
64
|
+
("no secret key", "no secret key for that key id in this keyring"),
|
|
65
|
+
("secret key not available", "no secret key for that key id in this keyring"),
|
|
66
|
+
("no default secret key", "no secret key for that key id in this keyring"),
|
|
67
|
+
("unusable secret key", "the secret key is unusable (expired or revoked)"),
|
|
68
|
+
("key has expired", "the key has expired"),
|
|
69
|
+
("expired", "the key has expired"),
|
|
70
|
+
("has been revoked", "the key has been revoked"),
|
|
71
|
+
("revoked", "the key has been revoked"),
|
|
72
|
+
("bad passphrase", "the passphrase was rejected"),
|
|
73
|
+
("passphrase", "the key needs a passphrase this environment cannot supply"),
|
|
74
|
+
("pinentry", "the key needs a passphrase this environment cannot supply"),
|
|
75
|
+
("inappropriate ioctl", "the key needs a passphrase this environment cannot supply"),
|
|
76
|
+
("no such file", "the key id does not resolve to a key in this keyring"),
|
|
77
|
+
("not found", "the key id does not resolve to a key in this keyring"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _classify(stderr, fallback):
|
|
82
|
+
"""Map gpg stderr onto a known reason. NEVER returns the stderr itself.
|
|
83
|
+
|
|
84
|
+
Anything unrecognized degrades to a generic fallback rather than leaking the
|
|
85
|
+
transcript, because the unrecognized case is exactly the one whose contents
|
|
86
|
+
cannot be predicted -- and therefore the one most likely to quote key
|
|
87
|
+
material or a passphrase prompt.
|
|
88
|
+
"""
|
|
89
|
+
low = (stderr or "").lower()
|
|
90
|
+
for needle, reason in _REASONS:
|
|
91
|
+
if needle in low:
|
|
92
|
+
return reason
|
|
93
|
+
return fallback
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _round_trip(key_id):
|
|
97
|
+
"""Sign a scratch payload with key_id and verify the signature.
|
|
98
|
+
|
|
99
|
+
Returns (True, None) when the signature verified, else (False, reason).
|
|
100
|
+
|
|
101
|
+
The argv MUST mirror autonomy/lib/proof-generator.py:_gpg_detached_sign and
|
|
102
|
+
autonomy/lib/proof-verify.py:_verify_gpg. A round trip that signs some other
|
|
103
|
+
way can succeed while the receipt pipeline still emits unsigned, which would
|
|
104
|
+
make this tool confidently wrong in the one direction that matters.
|
|
105
|
+
"""
|
|
106
|
+
tmp = tempfile.mkdtemp(prefix="loki-signing-status-")
|
|
107
|
+
try:
|
|
108
|
+
data_path = os.path.join(tmp, "payload.bin")
|
|
109
|
+
with open(data_path, "wb") as fh:
|
|
110
|
+
fh.write(_SCRATCH_PAYLOAD)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
signed = subprocess.run(
|
|
114
|
+
["gpg", "--batch", "--yes", "--armor", "--detach-sign",
|
|
115
|
+
"--local-user", key_id, "--output", "-"],
|
|
116
|
+
input=_SCRATCH_PAYLOAD, capture_output=True, timeout=_TIMEOUT,
|
|
117
|
+
)
|
|
118
|
+
except subprocess.TimeoutExpired:
|
|
119
|
+
# --batch should make gpg fail rather than prompt, but a wedged
|
|
120
|
+
# agent or a pinentry that ignores batch mode hangs instead. Never
|
|
121
|
+
# let this tool hang: a timeout IS a signing failure.
|
|
122
|
+
return False, "gpg timed out (a passphrase prompt is the usual cause)"
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
return False, "gpg could not be executed (%s)" % type(exc).__name__
|
|
125
|
+
|
|
126
|
+
if signed.returncode != 0 or not signed.stdout:
|
|
127
|
+
stderr = signed.stderr.decode("utf-8", errors="replace")
|
|
128
|
+
return False, _classify(stderr, "gpg refused to sign with that key")
|
|
129
|
+
|
|
130
|
+
sig_path = os.path.join(tmp, "payload.sig")
|
|
131
|
+
with open(sig_path, "wb") as fh:
|
|
132
|
+
fh.write(signed.stdout)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
checked = subprocess.run(
|
|
136
|
+
["gpg", "--verify", sig_path, data_path],
|
|
137
|
+
capture_output=True, timeout=_TIMEOUT,
|
|
138
|
+
)
|
|
139
|
+
except subprocess.TimeoutExpired:
|
|
140
|
+
return False, "gpg timed out verifying its own signature"
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
return False, "gpg could not be executed (%s)" % type(exc).__name__
|
|
143
|
+
|
|
144
|
+
if checked.returncode != 0:
|
|
145
|
+
# gpg produced bytes that gpg itself will not accept. A naive
|
|
146
|
+
# "signing worked, we got output" check calls this state OK.
|
|
147
|
+
stderr = checked.stderr.decode("utf-8", errors="replace")
|
|
148
|
+
return False, _classify(
|
|
149
|
+
stderr, "gpg produced a signature it could not verify")
|
|
150
|
+
|
|
151
|
+
return True, None
|
|
152
|
+
finally:
|
|
153
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def evaluate(env=None):
|
|
157
|
+
"""Resolve the signing state. Returns a JSON-safe dict.
|
|
158
|
+
|
|
159
|
+
Pure with respect to the keyring: reads it, changes nothing.
|
|
160
|
+
"""
|
|
161
|
+
env = os.environ if env is None else env
|
|
162
|
+
gpg_path = shutil.which("gpg")
|
|
163
|
+
key_id = (env.get("LOKI_PROOF_GPG_KEY") or "").strip()
|
|
164
|
+
|
|
165
|
+
result = {
|
|
166
|
+
"gpg_installed": gpg_path is not None,
|
|
167
|
+
"gpg_path": gpg_path,
|
|
168
|
+
"key_id_set": bool(key_id),
|
|
169
|
+
# A key ID is an identifier, not key material: safe to echo back so the
|
|
170
|
+
# user can see WHICH key was tried.
|
|
171
|
+
"key_id": key_id or None,
|
|
172
|
+
"round_trip_verified": False,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if gpg_path is None:
|
|
176
|
+
result["status"] = "gpg_absent"
|
|
177
|
+
result["reason"] = "gpg is not on PATH"
|
|
178
|
+
result["next_command"] = "brew install gnupg # or: apt-get install gnupg"
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
if not key_id:
|
|
182
|
+
result["status"] = "not_configured"
|
|
183
|
+
result["reason"] = "LOKI_PROOF_GPG_KEY is not set"
|
|
184
|
+
result["next_command"] = (
|
|
185
|
+
"gpg --list-secret-keys --keyid-format=long "
|
|
186
|
+
"# then: export LOKI_PROOF_GPG_KEY=<key-id>")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
verified, reason = _round_trip(key_id)
|
|
190
|
+
result["round_trip_verified"] = verified
|
|
191
|
+
|
|
192
|
+
# The single load-bearing line in this file. "ok" is reachable ONLY through
|
|
193
|
+
# a completed sign-and-verify round trip; every other path is a failure with
|
|
194
|
+
# a reason. Deriving status from gpg_installed and key_id_set instead would
|
|
195
|
+
# report ok for an expired key, which is the exact lie this tool exists to
|
|
196
|
+
# prevent.
|
|
197
|
+
result["status"] = "ok" if verified else "broken"
|
|
198
|
+
|
|
199
|
+
if verified:
|
|
200
|
+
result["reason"] = None
|
|
201
|
+
result["next_command"] = None
|
|
202
|
+
else:
|
|
203
|
+
result["reason"] = reason
|
|
204
|
+
result["next_command"] = (
|
|
205
|
+
"gpg --list-secret-keys --keyid-format=long "
|
|
206
|
+
"# confirm %s is present and usable" % key_id)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Exit codes are distinct per state so a caller can branch without parsing.
|
|
211
|
+
# 0 means, and only means, a round trip completed.
|
|
212
|
+
_EXIT = {"ok": 0, "broken": 1, "not_configured": 2, "gpg_absent": 3}
|
|
213
|
+
|
|
214
|
+
_HEADLINE = {
|
|
215
|
+
"ok": "SIGNED receipts from this machine carry a verifiable origin",
|
|
216
|
+
"broken": "BROKEN receipts are being emitted UNSIGNED, silently",
|
|
217
|
+
"not_configured": "UNSIGNED receipts prove integrity but NOT origin",
|
|
218
|
+
"gpg_absent": "UNSIGNED signing is unavailable on this machine",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render(result):
|
|
223
|
+
"""Human-readable report. Contains no gpg transcript, by construction."""
|
|
224
|
+
status = result["status"]
|
|
225
|
+
lines = ["Receipt signing: %s" % _HEADLINE[status], ""]
|
|
226
|
+
|
|
227
|
+
lines.append(" gpg installed: %s" % (
|
|
228
|
+
result["gpg_path"] if result["gpg_installed"] else "NO"))
|
|
229
|
+
lines.append(" LOKI_PROOF_GPG_KEY: %s" % (
|
|
230
|
+
result["key_id"] if result["key_id_set"] else "not set"))
|
|
231
|
+
lines.append(" sign+verify proof: %s" % (
|
|
232
|
+
"PASS (round trip completed)" if result["round_trip_verified"]
|
|
233
|
+
else "not proven"))
|
|
234
|
+
|
|
235
|
+
if result.get("reason"):
|
|
236
|
+
lines += ["", " Why: %s" % result["reason"]]
|
|
237
|
+
|
|
238
|
+
if status == "broken":
|
|
239
|
+
lines += [
|
|
240
|
+
"",
|
|
241
|
+
" A key is configured but cannot sign. The proof generator treats",
|
|
242
|
+
" signing as best-effort and swallows this failure, so receipts",
|
|
243
|
+
" keep emitting UNSIGNED with no error. Nothing else reports it.",
|
|
244
|
+
]
|
|
245
|
+
elif status == "not_configured":
|
|
246
|
+
lines += [
|
|
247
|
+
"",
|
|
248
|
+
" Nothing is broken. Signing is opt-in and off. Turn it on to prove",
|
|
249
|
+
" a receipt came from you and not merely that its bytes are intact.",
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
if result.get("next_command"):
|
|
253
|
+
lines += ["", " Next: %s" % result["next_command"]]
|
|
254
|
+
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main(argv=None):
|
|
259
|
+
parser = argparse.ArgumentParser(
|
|
260
|
+
description="Report whether this machine can produce SIGNED receipts.")
|
|
261
|
+
parser.add_argument("--json", action="store_true",
|
|
262
|
+
help="emit the result as JSON")
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
|
|
265
|
+
result = evaluate()
|
|
266
|
+
if args.json:
|
|
267
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
268
|
+
else:
|
|
269
|
+
print(render(result))
|
|
270
|
+
return _EXIT[result["status"]]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
sys.exit(main())
|