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 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.96.0
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.96.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.98.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.96.0
1
+ 8.98.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.96.0"
10
+ __version__ = "8.98.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.96.0";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Xf(Qf(import.meta.url)),Q=X$(X);h5=e_(Zf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var bO={};l0(bO,{runOrThrow:()=>jf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Tf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>yO});async function NQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function jf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Mf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Mf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Tf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return wf?"":Z}var wf,L0,F8,p0,zV0,a0,W8,Q9,v;var S6=p(()=>{wf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),zV0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),W8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as bf}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(bf(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>Kh});import{existsSync as Y9,readFileSync as g3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as rf}from"path";import{homedir as tf}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*LQ/X);if(J>LQ)J=LQ;let z=LQ-J,K=F8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${W8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function Zh(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
2
+ var m_=Object.create;var{getPrototypeOf:u_,defineProperty:eK,getOwnPropertyNames:p_}=Object;var d_=Object.prototype.hasOwnProperty;function c_(Z){return this[Z]}var l_,i_,a_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?l_??=new WeakMap:i_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?m_(u_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of p_(Z))if(!d_.call(K,$))eK(K,$,{get:c_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var s_=(Z)=>Z;function n_(Z,X){this[Z]=s_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:n_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as o_}from"url";import{existsSync as UQ}from"fs";import{homedir as r_}from"os";function t_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(r_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(o_(import.meta.url));i0=t_()});import{readFileSync as e_}from"fs";import{resolve as Zf,dirname as Xf}from"path";import{fileURLToPath as Qf}from"url";function h3(){if(h5!==null)return h5;let Z="8.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=3B5AE9CBA3FA521064756E2164756E21
1235
+ //# debugId=A8CE268C65C8280964756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '8.96.0'
78
+ __version__ = '8.98.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "8.96.0",
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.96.0",
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