loki-mode 9.2.0 → 9.3.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 v9.2.0
6
+ # Loki Mode v9.3.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
- **v9.2.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v9.3.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.2.0
1
+ 9.3.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.2.0"
10
+ __version__ = "9.3.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="9.2.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="9.3.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=F7F47FC30C33C44064756E2164756E21
1235
+ //# debugId=869F9122829FD31C64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.2.0'
78
+ __version__ = '9.3.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": "9.2.0",
4
+ "version": "9.3.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": "9.2.0",
5
+ "version": "9.3.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,211 @@
1
+ #!/usr/bin/env python3
2
+ """Diff two policy files by SAFETY DIRECTION, because a loosening looks like any other line.
3
+
4
+ WHY THIS EXISTS. tools/policy-load.py put the merge policy in a version-
5
+ controlled file so lowering a ceiling would look like a code change. It does.
6
+ So does RAISING one. In a 400-line PR diff, this:
7
+
8
+ - "max_usd": 5,
9
+ + "max_usd": 50,
10
+
11
+ is one line of JSON, the same visual weight as a renamed variable, and it has
12
+ just given every merge a 10x cost ceiling. `git diff` is direction-blind by
13
+ construction: it reports that a value changed, and leaves the safety reasoning
14
+ to a reviewer who is 300 lines from the end and has no schema in their head.
15
+
16
+ Doing that reasoning FOR the reviewer is the entire product. Not "max_usd:
17
+ 5 -> 50" -- that is what git already said. "WEAKENS".
18
+
19
+ Three rules follow, each one a way a loosening gets waved through:
20
+
21
+ A CHANGE WE CANNOT CLASSIFY READS "UNKNOWN DIRECTION", NEVER "unchanged" and
22
+ never assumed safe. policy-load.KNOWN_KEYS will grow. A key added there but
23
+ not here is a fully valid policy edit that diffs cleanly -- exactly the shape
24
+ a silent loosening hides in. Falling through to "changed" makes the new axis
25
+ invisible on the one surface built to see it. So the fallthrough is loud, and
26
+ a test asserts DIRECTIONS covers every KNOWN_KEYS entry -- UNKNOWN is the
27
+ runtime safety net for whatever still slips past, not the accepted resting
28
+ state for a key we ship.
29
+
30
+ REMOVING A KEY IS ALWAYS WEAKENING. Not "removed". Deleting `max_usd` does
31
+ not lower a ceiling, it stops enforcing cost AT ALL, which is strictly weaker
32
+ than any number you could have written. This holds for `require_receipt:
33
+ false -> absent` too, where the tempting reading is "it enforced nothing
34
+ either way, so it is neutral". A per-case exception here is the hole: the
35
+ reviewer needs one rule they can trust, not a rule with a footnote about
36
+ which removals are the safe kind.
37
+
38
+ BOTH FILES MUST BE VALID POLICIES FIRST. A diff of a file policy-load would
39
+ reject is a confident verdict about a document no gate would ever honour --
40
+ the vacuously-green shape one level over. Refuse, name the file, name the
41
+ reason.
42
+
43
+ Exit 0 means the diff was produced, INCLUDING when it found weakenings; that is
44
+ a successful answer to the question asked. --fail-on-weaken is what turns a
45
+ finding into a build failure, so CI can require a human ack on a loosening.
46
+
47
+ Usage:
48
+ tools/policy-diff.py <old.json> <new.json> [--json] [--fail-on-weaken]
49
+ """
50
+
51
+ import argparse
52
+ import importlib.util
53
+ import json
54
+ import os
55
+ import sys
56
+
57
+ sys.dont_write_bytecode = True
58
+
59
+ _HERE = os.path.dirname(os.path.abspath(__file__))
60
+
61
+ # policy-load.py is hyphenated, so it is not importable by name. Load it by
62
+ # path rather than re-implementing validation: a second copy of the schema is a
63
+ # second thing to forget to update, and this tool's whole claim is that it
64
+ # refuses exactly what the loader refuses.
65
+ _spec = importlib.util.spec_from_file_location(
66
+ "policy_load", os.path.join(_HERE, "policy-load.py"))
67
+ policy_load = importlib.util.module_from_spec(_spec)
68
+ _spec.loader.exec_module(policy_load)
69
+
70
+ WEAKENS = "WEAKENS"
71
+ TIGHTENS = "TIGHTENS"
72
+ UNKNOWN = "UNKNOWN DIRECTION"
73
+
74
+
75
+ class _Missing:
76
+ """A key absent from a policy. Not None: `null` is a value a file can hold."""
77
+
78
+ def __repr__(self):
79
+ return "<absent>"
80
+
81
+
82
+ MISSING = _Missing()
83
+
84
+ # Exit codes, per the convention tests/test_tool_exit_contract.py enforces.
85
+ EXIT_OK = 0
86
+ EXIT_FAILED = 1
87
+ EXIT_CANNOT_EVALUATE = 2
88
+ EXIT_USAGE = 64
89
+ EXIT_INPUT_MISSING = 66
90
+
91
+
92
+ def _direction_max_usd(old, new):
93
+ """A higher ceiling admits runs the old policy would have blocked."""
94
+ return WEAKENS if new > old else TIGHTENS
95
+
96
+
97
+ def _direction_require_receipt(old, new):
98
+ # Only false -> true tightens; true -> false drops the requirement.
99
+ return TIGHTENS if new else WEAKENS
100
+
101
+
102
+ # Keyed by policy key. The DEFAULT is UNKNOWN, never "changed": see the module
103
+ # docstring. Adding a key to policy-load.KNOWN_KEYS without adding it here is
104
+ # caught by a test, but until someone fixes it the output must still be loud.
105
+ DIRECTIONS = {
106
+ "max_usd": _direction_max_usd,
107
+ "require_receipt": _direction_require_receipt,
108
+ }
109
+
110
+
111
+ def classify(key, old, new):
112
+ """Return (direction, detail) for one key. `old`/`new` may be MISSING."""
113
+ if old == new:
114
+ return None, None
115
+ if new is MISSING:
116
+ # Never "removed". The gate stops enforcing this axis entirely, which
117
+ # is weaker than any value that could have been there.
118
+ return WEAKENS, "{} removed (was {}) -- this axis is no longer enforced at all".format(
119
+ key, json.dumps(old))
120
+ if old is MISSING:
121
+ return TIGHTENS, "{} added = {} -- a new axis is now enforced".format(
122
+ key, json.dumps(new))
123
+ decide = DIRECTIONS.get(key)
124
+ if decide is None:
125
+ return UNKNOWN, "{}: {} -> {} -- this tool does not know which direction is safer".format(
126
+ key, json.dumps(old), json.dumps(new))
127
+ return decide(old, new), "{}: {} -> {}".format(
128
+ key, json.dumps(old), json.dumps(new))
129
+
130
+
131
+ def diff(old_policy, new_policy):
132
+ """Every classified change between two validated policies."""
133
+ changes = []
134
+ for key in sorted(set(old_policy) | set(new_policy)):
135
+ direction, detail = classify(
136
+ key, old_policy.get(key, MISSING), new_policy.get(key, MISSING))
137
+ if direction is not None:
138
+ changes.append({"key": key, "direction": direction, "detail": detail})
139
+ return changes
140
+
141
+
142
+ def _load_or_die(path, label):
143
+ """Validated policy, or an exit code. Missing file and invalid file differ."""
144
+ if not os.path.exists(path):
145
+ print("policy-diff: {} file does not exist: {}".format(label, path),
146
+ file=sys.stderr)
147
+ return None, EXIT_INPUT_MISSING
148
+ try:
149
+ return policy_load.load(path), None
150
+ except policy_load.PolicyError as exc:
151
+ # Refusing to diff is "could not be checked", not "the check failed".
152
+ print("policy-diff: refusing to diff -- the {} file is not a valid "
153
+ "policy: {}".format(label, exc), file=sys.stderr)
154
+ return None, EXIT_CANNOT_EVALUATE
155
+
156
+
157
+ class _Parser(argparse.ArgumentParser):
158
+ # argparse exits 2 on a usage error, and 2 means "could not be checked" in
159
+ # this repo. Overriding error() (not parse_args) leaves --help exiting 0.
160
+ def error(self, message):
161
+ self.print_usage(sys.stderr)
162
+ print("{}: error: {}".format(self.prog, message), file=sys.stderr)
163
+ raise SystemExit(EXIT_USAGE)
164
+
165
+
166
+ def main(argv=None):
167
+ ap = _Parser(description="Classify every policy change as WEAKENS or TIGHTENS.")
168
+ ap.add_argument("old", help="the policy file as it is today")
169
+ ap.add_argument("new", help="the policy file as proposed")
170
+ ap.add_argument("--json", action="store_true", dest="as_json",
171
+ help="emit the classified changes as JSON")
172
+ ap.add_argument("--fail-on-weaken", action="store_true", dest="fail_on_weaken",
173
+ help="exit non-zero if any change weakens the gate")
174
+ args = ap.parse_args(argv)
175
+
176
+ old, rc = _load_or_die(args.old, "old")
177
+ if rc is not None:
178
+ return rc
179
+ new, rc = _load_or_die(args.new, "new")
180
+ if rc is not None:
181
+ return rc
182
+
183
+ changes = diff(old, new)
184
+ weakenings = [c for c in changes if c["direction"] == WEAKENS]
185
+ unknowns = [c for c in changes if c["direction"] == UNKNOWN]
186
+
187
+ if args.as_json:
188
+ print(json.dumps({
189
+ "changes": changes,
190
+ "weakens": len(weakenings),
191
+ "unknown_direction": len(unknowns),
192
+ }, indent=2, sort_keys=True))
193
+ elif not changes:
194
+ print("no change: the two policies enforce the same gate")
195
+ else:
196
+ for c in changes:
197
+ print("{}: {}".format(c["direction"], c["detail"]))
198
+ # UNKNOWN is called out beside the weakenings, not buried in the list:
199
+ # it is the count a reviewer must resolve by hand.
200
+ print("{} weakening(s), {} unknown direction, {} change(s) total".format(
201
+ len(weakenings), len(unknowns), len(changes)))
202
+
203
+ if weakenings and args.fail_on_weaken:
204
+ print("policy-diff: {} weakening(s) require an explicit human ack".format(
205
+ len(weakenings)), file=sys.stderr)
206
+ return EXIT_FAILED
207
+ return EXIT_OK
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())