loki-mode 8.96.0 → 8.98.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/gate-report.py +248 -0
- package/tools/policy-load.py +196 -0
- package/tools/signing-status.py +274 -0
- package/tools/verify-demo.sh +223 -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.98.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.98.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
8.
|
|
1
|
+
8.98.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.98.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=A8CE268C65C8280964756E2164756E21
|
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.98.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.98.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,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())
|
|
@@ -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())
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Zero-cost demonstration of the verification chain, on receipts it builds
|
|
4
|
+
# itself. The point a prospective user cannot otherwise see without paying for
|
|
5
|
+
# a full build: this system re-checks its own output, and says so honestly when
|
|
6
|
+
# a receipt does not hold up.
|
|
7
|
+
#
|
|
8
|
+
# tools/verify-demo.sh [--keep]
|
|
9
|
+
#
|
|
10
|
+
# WHAT MAKES THIS HONEST, AND WHY IT IS BUILT THIS WAY.
|
|
11
|
+
#
|
|
12
|
+
# Every verdict below is printed by the REAL tool, verbatim. Nothing here
|
|
13
|
+
# echoes a "VERIFIED" of its own. A demo that printed its own verdicts would
|
|
14
|
+
# keep printing them after the verifier broke -- it would be a claim about the
|
|
15
|
+
# product rather than an exercise of it, which is the same defect the tools it
|
|
16
|
+
# demonstrates exist to prevent. So each step runs the actual script, shows the
|
|
17
|
+
# actual output, and asserts the actual exit code. If the chain regresses, this
|
|
18
|
+
# fails; that is deliberate, and makes it a smoke test as well as a demo.
|
|
19
|
+
#
|
|
20
|
+
# The receipts are SYNTHETIC. A scratch git repo with one commit and one
|
|
21
|
+
# uncommitted edit is created in a tempdir, and the real proof generator is run
|
|
22
|
+
# over it. Synthetic means no build ran, no provider was contacted and no money
|
|
23
|
+
# was spent -- it does NOT mean the receipt is fake. It is a genuine receipt
|
|
24
|
+
# about a trivially small piece of real work, which is exactly what lets the
|
|
25
|
+
# real verifier reach a real VERIFIED instead of a hedge.
|
|
26
|
+
#
|
|
27
|
+
# The scratch repo is a fresh mktemp -d. Nothing is written inside the user's
|
|
28
|
+
# own .loki, and the demo never touches global git config.
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
KEEP=0
|
|
33
|
+
for arg in "$@"; do
|
|
34
|
+
case "$arg" in
|
|
35
|
+
--keep) KEEP=1 ;;
|
|
36
|
+
-h|--help) sed -n '2,28p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'; exit 0 ;;
|
|
37
|
+
*) echo "verify-demo: unknown argument: $arg" >&2; exit 64 ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
42
|
+
ROOT="$(cd "$HERE/.." && pwd)"
|
|
43
|
+
|
|
44
|
+
GENERATOR="$ROOT/autonomy/lib/proof-generator.py"
|
|
45
|
+
ATTEST="$HERE/receipt-attest.py"
|
|
46
|
+
GATE="$HERE/ci-gate.py"
|
|
47
|
+
|
|
48
|
+
# Requirement: name the missing tool and stop. A demo that skipped a step and
|
|
49
|
+
# still reported success would be asserting a capability it never exercised.
|
|
50
|
+
missing=0
|
|
51
|
+
for tool in "$GENERATOR" "$ATTEST" "$GATE"; do
|
|
52
|
+
if [ ! -f "$tool" ]; then
|
|
53
|
+
echo "verify-demo: required tool missing: $tool" >&2
|
|
54
|
+
missing=1
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
57
|
+
if ! command -v git >/dev/null 2>&1; then
|
|
58
|
+
echo "verify-demo: required tool missing: git" >&2
|
|
59
|
+
missing=1
|
|
60
|
+
fi
|
|
61
|
+
if [ "$missing" -ne 0 ]; then
|
|
62
|
+
echo "verify-demo: cannot demonstrate the verification chain with a tool absent" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/loki-verify-demo-XXXXXX")"
|
|
67
|
+
|
|
68
|
+
cleanup() {
|
|
69
|
+
if [ "$KEEP" -eq 1 ]; then
|
|
70
|
+
echo ""
|
|
71
|
+
echo "scratch kept at: $SCRATCH"
|
|
72
|
+
else
|
|
73
|
+
rm -rf "$SCRATCH"
|
|
74
|
+
fi
|
|
75
|
+
}
|
|
76
|
+
trap cleanup EXIT
|
|
77
|
+
|
|
78
|
+
rule() { printf '%s\n' "------------------------------------------------------------"; }
|
|
79
|
+
|
|
80
|
+
step_failed() {
|
|
81
|
+
echo ""
|
|
82
|
+
echo "verify-demo: FAILED -- $1" >&2
|
|
83
|
+
echo "verify-demo: the verification chain did not behave as this demo claims." >&2
|
|
84
|
+
exit 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cat <<BANNER
|
|
88
|
+
============================================================
|
|
89
|
+
Loki Mode -- verification chain demo
|
|
90
|
+
============================================================
|
|
91
|
+
|
|
92
|
+
THESE RECEIPTS ARE SYNTHETIC.
|
|
93
|
+
|
|
94
|
+
No build was run. No provider was contacted. No money was
|
|
95
|
+
spent. This creates a scratch git repository, generates a
|
|
96
|
+
real receipt over it with the real generator, and then runs
|
|
97
|
+
the real verification tools against that receipt.
|
|
98
|
+
|
|
99
|
+
Every verdict below is printed by the tool named above it.
|
|
100
|
+
Nothing in this script prints a verdict of its own.
|
|
101
|
+
|
|
102
|
+
scratch workspace: $SCRATCH
|
|
103
|
+
BANNER
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Build the synthetic workspace: a real repo, a real commit, a real edit.
|
|
107
|
+
# Small, but genuinely present on disk, which is what the verifier re-derives
|
|
108
|
+
# the receipt's git facts from.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
cd "$SCRATCH" || step_failed "could not enter the scratch workspace"
|
|
111
|
+
|
|
112
|
+
git init -q . 2>/dev/null || step_failed "could not create the scratch git repository"
|
|
113
|
+
# Repo-local only. The user's global git identity is never touched.
|
|
114
|
+
git config user.name "loki-verify-demo" || step_failed "could not set scratch git identity"
|
|
115
|
+
git config user.email "verify-demo@loki.invalid" || step_failed "could not set scratch git identity"
|
|
116
|
+
|
|
117
|
+
printf 'print("hello")\n' > app.py
|
|
118
|
+
git add app.py || step_failed "could not stage the scratch file"
|
|
119
|
+
git commit -qm "synthetic baseline" || step_failed "could not commit the scratch baseline"
|
|
120
|
+
printf 'print("goodbye")\n' >> app.py
|
|
121
|
+
|
|
122
|
+
mkdir -p .loki/proofs
|
|
123
|
+
|
|
124
|
+
echo ""
|
|
125
|
+
rule
|
|
126
|
+
echo "STEP 1 of 3 -- generate a receipt over the synthetic workspace"
|
|
127
|
+
echo " \$ autonomy/lib/proof-generator.py --out-dir .loki/proofs/good"
|
|
128
|
+
rule
|
|
129
|
+
if ! python3 "$GENERATOR" --loki-dir .loki --out-dir .loki/proofs/good \
|
|
130
|
+
--provider claude --quiet; then
|
|
131
|
+
step_failed "the real proof generator did not produce a receipt"
|
|
132
|
+
fi
|
|
133
|
+
GOOD="$SCRATCH/.loki/proofs/good/proof.json"
|
|
134
|
+
[ -f "$GOOD" ] || step_failed "the generator reported success but wrote no proof.json"
|
|
135
|
+
echo "wrote $(basename "$GOOD") ($(wc -c < "$GOOD" | tr -d ' ') bytes)"
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# 2. The honest receipt verifies.
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
echo ""
|
|
141
|
+
rule
|
|
142
|
+
echo "STEP 2 of 3 -- verify the untouched receipt"
|
|
143
|
+
echo " \$ tools/receipt-attest.py .loki/proofs/good/proof.json"
|
|
144
|
+
rule
|
|
145
|
+
python3 "$ATTEST" "$GOOD"
|
|
146
|
+
good_exit=$?
|
|
147
|
+
if [ "$good_exit" -ne 0 ]; then
|
|
148
|
+
step_failed "an untouched receipt did not attest cleanly (exit $good_exit, expected 0)"
|
|
149
|
+
fi
|
|
150
|
+
echo ""
|
|
151
|
+
echo " -> exit 0. Every scored axis was checked here, and passed."
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# 3. Tamper with a recorded FACT, not the formatting. The integrity hash is
|
|
155
|
+
# taken over canonicalized bytes, so re-indenting alone proves nothing;
|
|
156
|
+
# changing a claim the receipt makes is the case worth showing.
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
mkdir -p "$SCRATCH/.loki/proofs/tampered"
|
|
159
|
+
python3 - "$GOOD" "$SCRATCH/.loki/proofs/tampered/proof.json" <<'PY' || step_failed "could not write the tampered receipt"
|
|
160
|
+
import json, sys
|
|
161
|
+
src, dst = sys.argv[1], sys.argv[2]
|
|
162
|
+
proof = json.loads(open(src).read())
|
|
163
|
+
# Inflate the recorded file count: the receipt now claims more work than the
|
|
164
|
+
# repository contains. This is the realistic forgery, and it is a value inside
|
|
165
|
+
# the hashed body rather than a formatting change.
|
|
166
|
+
proof["facts"]["git"]["diff"]["count"] = 999
|
|
167
|
+
open(dst, "w").write(json.dumps(proof, indent=2))
|
|
168
|
+
PY
|
|
169
|
+
|
|
170
|
+
echo ""
|
|
171
|
+
rule
|
|
172
|
+
echo "STEP 3 of 3 -- tamper with the receipt, then re-verify"
|
|
173
|
+
echo " edited facts.git.diff.count: 1 -> 999 (claims work that is not there)"
|
|
174
|
+
echo " \$ tools/receipt-attest.py .loki/proofs/tampered/proof.json"
|
|
175
|
+
rule
|
|
176
|
+
python3 "$ATTEST" "$SCRATCH/.loki/proofs/tampered/proof.json"
|
|
177
|
+
bad_exit=$?
|
|
178
|
+
if [ "$bad_exit" -eq 0 ]; then
|
|
179
|
+
step_failed "a tampered receipt attested as clean (exit 0) -- the verifier did not catch it"
|
|
180
|
+
fi
|
|
181
|
+
echo ""
|
|
182
|
+
echo " -> exit $bad_exit. Caught, with the reason printed above."
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# 4. The gate refuses. A verdict a human reads is not a gate; an exit code CI
|
|
186
|
+
# branches on is. Only the tampered receipt is left in place.
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Moved aside rather than deleted: the gate must see ONLY the tampered receipt
|
|
189
|
+
# (it attests the newest one under .loki/proofs/*), while --keep still leaves
|
|
190
|
+
# the good receipt on disk for anyone -- or any test -- wanting to re-hash it
|
|
191
|
+
# and confirm the digest printed above came from these exact bytes.
|
|
192
|
+
mkdir -p "$SCRATCH/generated"
|
|
193
|
+
mv "$SCRATCH/.loki/proofs/good/proof.json" "$SCRATCH/generated/proof.json" \
|
|
194
|
+
|| step_failed "could not set aside the good receipt"
|
|
195
|
+
rm -rf "$SCRATCH/.loki/proofs/good"
|
|
196
|
+
|
|
197
|
+
echo ""
|
|
198
|
+
rule
|
|
199
|
+
echo "AND THE GATE REFUSES TO PASS"
|
|
200
|
+
echo " with only the tampered receipt present:"
|
|
201
|
+
echo " \$ tools/ci-gate.py . --require-receipt"
|
|
202
|
+
rule
|
|
203
|
+
python3 "$GATE" "$SCRATCH" --require-receipt
|
|
204
|
+
gate_exit=$?
|
|
205
|
+
if [ "$gate_exit" -eq 0 ]; then
|
|
206
|
+
step_failed "the CI gate passed (exit 0) on a tampered receipt"
|
|
207
|
+
fi
|
|
208
|
+
echo ""
|
|
209
|
+
echo " -> exit $gate_exit. A CI job branching on this exit code stops here."
|
|
210
|
+
|
|
211
|
+
cat <<'SUMMARY'
|
|
212
|
+
|
|
213
|
+
============================================================
|
|
214
|
+
Every verdict above was printed by the real tool, over
|
|
215
|
+
receipts generated on this machine seconds ago.
|
|
216
|
+
|
|
217
|
+
Reminder: SYNTHETIC receipts. No build ran, no provider was
|
|
218
|
+
contacted, no money was spent. This demonstrates the
|
|
219
|
+
verification chain; it does not certify any real work.
|
|
220
|
+
============================================================
|
|
221
|
+
SUMMARY
|
|
222
|
+
|
|
223
|
+
exit 0
|