loki-mode 9.22.0 → 9.22.2
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/autonomy/loki +58 -0
- package/dashboard/__init__.py +1 -1
- package/docs/OUTCOME-CANARY-EVALUATION.md +84 -6
- 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/outcome-canary-evaluate.py +13 -3
- package/tools/outcome-canary-observe.py +261 -0
- package/tools/outcome-canary-receipt-verify.py +183 -0
- package/tools/outcome-canary-receipt.py +235 -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 v9.22.
|
|
6
|
+
# Loki Mode v9.22.2
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
470
470
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
|
-
**v9.22.
|
|
473
|
+
**v9.22.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
9.22.
|
|
1
|
+
9.22.2
|
package/autonomy/loki
CHANGED
|
@@ -32219,6 +32219,55 @@ if _result.get('error'):
|
|
|
32219
32219
|
# this is a thin dispatcher, mirroring how cmd_proof delegates to the proof
|
|
32220
32220
|
# generator. Generation is incremental (skips when the codebase is unchanged).
|
|
32221
32221
|
# =============================================================================
|
|
32222
|
+
# loki outcomes canary: installed access to the complete local canary workflow.
|
|
32223
|
+
#
|
|
32224
|
+
# Keep this as a thin dispatcher. Each Python tool retains its own explicit
|
|
32225
|
+
# opt-in gate, input validation, privacy contract, writes (where applicable),
|
|
32226
|
+
# and exit codes. Resolving from the installed Loki root makes the workflow
|
|
32227
|
+
# usable from any current directory without copying repository-relative paths.
|
|
32228
|
+
cmd_canary() {
|
|
32229
|
+
local subcommand="${1:-}"
|
|
32230
|
+
local tool_name=""
|
|
32231
|
+
local tools_dir="${_LOKI_SCRIPT_DIR}/../tools"
|
|
32232
|
+
|
|
32233
|
+
case "$subcommand" in
|
|
32234
|
+
plan) tool_name="outcome-canary.py" ;;
|
|
32235
|
+
record) tool_name="outcome-canary-observe.py" ;;
|
|
32236
|
+
evaluate) tool_name="outcome-canary-evaluate.py" ;;
|
|
32237
|
+
receipt) tool_name="outcome-canary-receipt.py" ;;
|
|
32238
|
+
verify) tool_name="outcome-canary-receipt-verify.py" ;;
|
|
32239
|
+
--help|-h|help|"")
|
|
32240
|
+
echo -e "${BOLD}loki outcomes canary${NC} - run the local outcome-routing canary workflow"
|
|
32241
|
+
echo ""
|
|
32242
|
+
echo "Usage: loki outcomes canary <command> [options]"
|
|
32243
|
+
echo ""
|
|
32244
|
+
echo "Commands:"
|
|
32245
|
+
echo " plan Plan a deterministic reversible canary assignment"
|
|
32246
|
+
echo " record Record one explicitly consented local observation"
|
|
32247
|
+
echo " evaluate Produce an aggregate PROMOTE, HOLD, or ROLLBACK verdict"
|
|
32248
|
+
echo " receipt Create one portable immutable decision receipt"
|
|
32249
|
+
echo " verify Independently verify a receipt against current evidence"
|
|
32250
|
+
echo ""
|
|
32251
|
+
echo "Every mutating or decision-producing step remains explicitly opt-in."
|
|
32252
|
+
echo "Run 'loki outcomes canary <command> --help' for its exact arguments."
|
|
32253
|
+
return 0
|
|
32254
|
+
;;
|
|
32255
|
+
*)
|
|
32256
|
+
echo "loki outcomes canary: unknown command: $subcommand" >&2
|
|
32257
|
+
echo "Run 'loki outcomes canary --help' for usage." >&2
|
|
32258
|
+
return 64
|
|
32259
|
+
;;
|
|
32260
|
+
esac
|
|
32261
|
+
|
|
32262
|
+
shift
|
|
32263
|
+
local tool="${tools_dir}/${tool_name}"
|
|
32264
|
+
if [ ! -f "$tool" ]; then
|
|
32265
|
+
echo "loki outcomes canary: installed tool is unavailable: $tool_name" >&2
|
|
32266
|
+
return 2
|
|
32267
|
+
fi
|
|
32268
|
+
python3 "$tool" "$@"
|
|
32269
|
+
}
|
|
32270
|
+
|
|
32222
32271
|
# loki outcomes: what happened to the work AFTER the receipt was written.
|
|
32223
32272
|
#
|
|
32224
32273
|
# Every competing agent reports VOLUME. Factory AI's analytics expose files
|
|
@@ -32233,6 +32282,11 @@ if _result.get('error'):
|
|
|
32233
32282
|
# feature. See autonomy/lib/outcome_ledger.py for the anchor gate and the
|
|
32234
32283
|
# measured evidence behind it.
|
|
32235
32284
|
cmd_outcomes() {
|
|
32285
|
+
if [ "${1:-}" = "canary" ]; then
|
|
32286
|
+
shift
|
|
32287
|
+
cmd_canary "$@"
|
|
32288
|
+
return $?
|
|
32289
|
+
fi
|
|
32236
32290
|
local lib="${_LOKI_SCRIPT_DIR}/lib/outcome_ledger.py"
|
|
32237
32291
|
if [ ! -f "$lib" ]; then
|
|
32238
32292
|
echo "outcome ledger is not installed at $lib" >&2
|
|
@@ -32243,6 +32297,7 @@ cmd_outcomes() {
|
|
|
32243
32297
|
echo -e "${BOLD}loki outcomes${NC} - did the work turn out to be RIGHT, not just that it happened"
|
|
32244
32298
|
echo ""
|
|
32245
32299
|
echo "Usage: loki outcomes [--json] [--run-id <id>]"
|
|
32300
|
+
echo " loki outcomes canary <command> [options]"
|
|
32246
32301
|
echo ""
|
|
32247
32302
|
echo "Follows each Evidence Receipt past the moment it was written and"
|
|
32248
32303
|
echo "reports, from local git only: was it reverted, did its lines survive,"
|
|
@@ -32251,6 +32306,9 @@ cmd_outcomes() {
|
|
|
32251
32306
|
echo "A receipt is measured ONLY when sha algebra proves base..head is that"
|
|
32252
32307
|
echo "change. Everything else reads UNKNOWN with a named reason -- never 0,"
|
|
32253
32308
|
echo "never a pass. Read-only: it never writes to the repo it analyses."
|
|
32309
|
+
echo ""
|
|
32310
|
+
echo "'loki outcomes canary' runs the local plan, record, evaluate, receipt,"
|
|
32311
|
+
echo "and verify workflow; each step retains its explicit opt-in gate."
|
|
32254
32312
|
return 0
|
|
32255
32313
|
;;
|
|
32256
32314
|
esac
|
package/dashboard/__init__.py
CHANGED
|
@@ -4,13 +4,88 @@
|
|
|
4
4
|
observations into one deterministic aggregate verdict. It never invokes a provider,
|
|
5
5
|
changes an assignment, or promotes a route.
|
|
6
6
|
|
|
7
|
+
Installed Loki distributions expose the complete workflow through one command:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
loki outcomes canary plan --help
|
|
11
|
+
loki outcomes canary record --help
|
|
12
|
+
loki outcomes canary evaluate --help
|
|
13
|
+
loki outcomes canary receipt --help
|
|
14
|
+
loki outcomes canary verify --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`loki outcomes canary` resolves the bundled tools from the installation, so these commands
|
|
18
|
+
work outside the Loki source checkout. It passes arguments and exit codes through
|
|
19
|
+
unchanged; the explicit opt-in gates documented below still apply.
|
|
20
|
+
|
|
7
21
|
```bash
|
|
8
|
-
|
|
22
|
+
loki outcomes canary evaluate report.json observations.json \
|
|
9
23
|
--enable-evaluation --control-route safe --canary-percent 10 --json
|
|
10
24
|
```
|
|
11
25
|
|
|
12
26
|
Evaluation is opt-in. Without `--enable-evaluation`, the command refuses.
|
|
13
27
|
|
|
28
|
+
## Retain a decision receipt
|
|
29
|
+
|
|
30
|
+
After a complete evaluation, create one immutable portable proof by independently
|
|
31
|
+
rerunning the same decision:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
loki outcomes canary receipt report.json observations.json receipt.json \
|
|
35
|
+
--enable-receipt --control-route safe --canary-percent 10 --min-samples 5
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The command creates a new canonical `loki-outcome-canary-decision-receipt/v1`
|
|
39
|
+
file. It binds the exact report, source, and observation digests; the full
|
|
40
|
+
evaluation policy; privacy-safe aggregate arm results; the acceptance delta; and
|
|
41
|
+
the `PROMOTE`, `HOLD`, or `ROLLBACK` verdict. It never includes subject keys or
|
|
42
|
+
local input/output pathnames, invokes a provider, or changes a route.
|
|
43
|
+
|
|
44
|
+
Receipt creation is explicit and create-only. An existing file or symlink is
|
|
45
|
+
never replaced, and a target created by another process wins without being
|
|
46
|
+
changed. Refused or sparse evaluation, malformed or oversized inputs, unsafe
|
|
47
|
+
path indirection, source drift, evidence drift, and publication failure leave no
|
|
48
|
+
receipt claim. The output is written with mode `0600`, fsynced, and published
|
|
49
|
+
atomically in its destination directory.
|
|
50
|
+
|
|
51
|
+
Verify a handed-off receipt against the exact current evidence before acting on
|
|
52
|
+
its verdict:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
loki outcomes canary verify report.json observations.json receipt.json \
|
|
56
|
+
--enable-verification --json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The read-only verifier takes the policy from the canonical receipt, independently
|
|
60
|
+
reruns the deterministic evaluation, rechecks the report, source, and observation
|
|
61
|
+
bytes, and requires the complete rederived receipt to match exactly. It returns
|
|
62
|
+
`VERIFIED` only for the same bound evidence, aggregates, policy, and verdict.
|
|
63
|
+
Missing, symlinked, oversized, malformed, non-canonical, sparse, refused,
|
|
64
|
+
substituted, or drifted inputs fail closed without writes, telemetry, provider
|
|
65
|
+
calls, or route changes.
|
|
66
|
+
|
|
67
|
+
## Record an observation
|
|
68
|
+
|
|
69
|
+
Use the installed recorder instead of hand-editing the observation contract:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
loki outcomes canary record report.json observations.json \
|
|
73
|
+
--enable-recording --subject '<locally chosen opaque key>' \
|
|
74
|
+
--accepted --risk 0.10 --control-route safe --canary-percent 10
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Choose exactly one of `--accepted` or `--rejected`. The risk value is a finite
|
|
78
|
+
number from zero through one. The recorder independently reproduces the subject's
|
|
79
|
+
deterministic assignment and route, then creates or appends one canonical item
|
|
80
|
+
through an atomic local replacement. Existing report and source bindings must
|
|
81
|
+
match, and every prior item is rebound before the append is accepted.
|
|
82
|
+
|
|
83
|
+
Recording is explicit and local: the command never invokes a provider, changes a
|
|
84
|
+
route, or emits the subject or input pathname. Duplicate subjects, symlinks,
|
|
85
|
+
malformed or oversized evidence, binding drift, assignment drift, and unsafe
|
|
86
|
+
values are refused without changing the observation file. A sidecar `.lock` file
|
|
87
|
+
serializes cooperating writers.
|
|
88
|
+
|
|
14
89
|
## Observation contract
|
|
15
90
|
|
|
16
91
|
The input is one `loki-outcome-canary-observations/v1` JSON object:
|
|
@@ -37,10 +112,12 @@ Each item has exactly those five fields. Subjects must be unique and non-empty;
|
|
|
37
112
|
The file is capped at 5 MiB and 100,000 observations. Duplicate JSON keys are
|
|
38
113
|
rejected.
|
|
39
114
|
|
|
40
|
-
The evaluator
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
115
|
+
The evaluator accepts only named regular report and observation files. Symlinks,
|
|
116
|
+
directories, devices, and other path indirection are refused. It hashes the exact
|
|
117
|
+
report and observation bytes, rechecks the report's underlying source digest, and
|
|
118
|
+
reruns the released deterministic canary assignment for every subject. A recorded
|
|
119
|
+
arm or route that does not match that assignment refuses the entire evaluation.
|
|
120
|
+
Output contains no subject keys or input pathnames.
|
|
44
121
|
|
|
45
122
|
## Verdict policy
|
|
46
123
|
|
|
@@ -60,6 +137,7 @@ unbound evidence returns `REFUSED` rather than a partial verdict.
|
|
|
60
137
|
## Output and exit codes
|
|
61
138
|
|
|
62
139
|
`--json` emits the aggregate arms, policy, exact evidence digests, verdict, and
|
|
63
|
-
refusal reasons.
|
|
140
|
+
refusal reasons. It is portable across machines because it omits local input paths.
|
|
141
|
+
The default output is a short human-readable summary. Exit 0 means
|
|
64
142
|
a verdict was produced (including `ROLLBACK`), 3 means evaluation was refused, 64
|
|
65
143
|
is an invocation error, and 66 is a missing input file.
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.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 o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;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(kO,"..","..","..")}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(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.22.
|
|
2
|
+
var p_=Object.create;var{getPrototypeOf:d_,defineProperty:eK,getOwnPropertyNames:c_}=Object;var l_=Object.prototype.hasOwnProperty;function i_(Z){return this[Z]}var a_,s_,n_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?a_??=new WeakMap:s_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?p_(d_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of c_(Z))if(!l_.call(K,$))eK(K,$,{get:i_.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 o_=(Z)=>Z;function r_(Z,X){this[Z]=o_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:r_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var xO={};l0(xO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as t_}from"url";import{existsSync as UQ}from"fs";import{homedir as e_}from"os";function Zf(){let Z=kO;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(kO,"..","..","..")}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(e_(),".loki")}var kO,i0;var H8=p(()=>{kO=Z$(t_(import.meta.url));i0=Zf()});import{readFileSync as Xf}from"fs";import{resolve as Qf,dirname as Yf}from"path";import{fileURLToPath as Jf}from"url";function h3(){if(h5!==null)return h5;let Z="9.22.2";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Yf(Jf(import.meta.url)),Q=X$(X);h5=Xf(Qf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var _O={};l0(_O,{runOrThrow:()=>Tf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>Cf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>bO});async function NQ(Z,X=bO){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 V=$.byteLength-(z-X);J+=Y.decode($.subarray(0,V),{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 Tf(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=wf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function wf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Cf(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 bO=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 Ff?"":Z}var Ff,A0,F8,_0,$W0,a0,V8,Q9,h;var S6=p(()=>{Ff=(process.env.NO_COLOR??"").length>0;A0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),_0=o7("\x1B[1;33m"),$W0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),V8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),h=o7("\x1B[0m")});import{existsSync as ff}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(ff(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 XA={};l0(XA,{runStatus:()=>Vh});import{existsSync as Y9,readFileSync as g3,readdirSync as aO,statSync as sO}from"fs";import{resolve as h8,basename as ef}from"path";import{homedir as Zh}from"os";function nO(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 oO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*AQ/X);if(J>AQ)J=AQ;let z=AQ-J,K=F8;if(Y>=80)K=A0;else if(Y>=50)K=_0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),V=nO(Z),W=nO(X);return` ${V8}${Q}${h} ${K}[${$}]${h} ${Y}% (${V} / ${W})`}async function Qh(){if(await X9("jq"))return!0;return process.stdout.write(`${A0}Error: jq is required but not installed.${h}
|
|
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)
|
|
@@ -1236,4 +1236,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
1236
1236
|
`),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (g_(),v_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
|
|
1237
1237
|
`),process.stderr.write(m_),2}}lO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var dV0=await pV0(Bun.argv.slice(2));process.exit(dV0);
|
|
1238
1238
|
|
|
1239
|
-
//# debugId=
|
|
1239
|
+
//# debugId=1B1E1BA0D9D91D1E64756E2164756E21
|
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": "9.22.
|
|
4
|
+
"version": "9.22.2",
|
|
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.22.
|
|
5
|
+
"version": "9.22.2",
|
|
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",
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import math
|
|
8
8
|
import os
|
|
9
9
|
import pathlib
|
|
10
|
+
import stat
|
|
10
11
|
import sys
|
|
11
12
|
|
|
12
13
|
OK, REFUSED, USAGE, NO_INPUT = 0, 3, 64, 66
|
|
@@ -53,7 +54,17 @@ def _sha256(data):
|
|
|
53
54
|
return hashlib.sha256(data).hexdigest()
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
def _require_named_regular_file(path, label):
|
|
58
|
+
try:
|
|
59
|
+
mode = os.lstat(path).st_mode
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
raise ValueError(f"{label} cannot be inspected: {exc}") from exc
|
|
62
|
+
if not stat.S_ISREG(mode):
|
|
63
|
+
raise ValueError(f"{label} must be a named regular file")
|
|
64
|
+
|
|
65
|
+
|
|
56
66
|
def _read_json(path):
|
|
67
|
+
_require_named_regular_file(path, "input")
|
|
57
68
|
with open(path, "rb") as handle:
|
|
58
69
|
data = handle.read(MAX_BYTES + 1)
|
|
59
70
|
if len(data) > MAX_BYTES:
|
|
@@ -105,10 +116,8 @@ def evaluate(report_path, observations_path, control_route, canary_percent=10.0,
|
|
|
105
116
|
|
|
106
117
|
out = {
|
|
107
118
|
"evaluation": DOMAIN,
|
|
108
|
-
"report": os.path.abspath(report_path),
|
|
109
119
|
"report_sha256": None,
|
|
110
120
|
"source_sha256": None,
|
|
111
|
-
"observations": os.path.abspath(observations_path),
|
|
112
121
|
"observations_sha256": None,
|
|
113
122
|
"control_route": control_route,
|
|
114
123
|
"canary_route": None,
|
|
@@ -141,6 +150,7 @@ def evaluate(report_path, observations_path, control_route, canary_percent=10.0,
|
|
|
141
150
|
return out
|
|
142
151
|
|
|
143
152
|
try:
|
|
153
|
+
_require_named_regular_file(report_path, "report")
|
|
144
154
|
planner = _load_planner()
|
|
145
155
|
report, report_sha256, report_reasons = planner.load_report(report_path)
|
|
146
156
|
except Exception as exc:
|
|
@@ -228,7 +238,7 @@ def main(argv=None):
|
|
|
228
238
|
parser.add_argument("--json", action="store_true")
|
|
229
239
|
args = parser.parse_args(argv)
|
|
230
240
|
for path in (args.report, args.observations):
|
|
231
|
-
if not os.path.
|
|
241
|
+
if not os.path.lexists(path):
|
|
232
242
|
print(f"outcome-canary-evaluate: no such file: {path}", file=sys.stderr)
|
|
233
243
|
return NO_INPUT
|
|
234
244
|
result = evaluate(
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Atomically record one consented, source-bound canary observation."""
|
|
3
|
+
import argparse
|
|
4
|
+
import fcntl
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
OK, REFUSED, USAGE, NO_INPUT = 0, 3, 64, 66
|
|
16
|
+
DOMAIN = "loki-outcome-canary-observation-record/v1"
|
|
17
|
+
OBSERVATIONS = "loki-outcome-canary-observations/v1"
|
|
18
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
19
|
+
MAX_ITEMS = 100_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Parser(argparse.ArgumentParser):
|
|
23
|
+
def error(self, message):
|
|
24
|
+
self.print_usage(sys.stderr)
|
|
25
|
+
self.exit(USAGE, f"{self.prog}: error: {message}\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_tool(filename, module_name):
|
|
29
|
+
path = pathlib.Path(__file__).with_name(filename)
|
|
30
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
31
|
+
if spec is None or spec.loader is None:
|
|
32
|
+
raise RuntimeError("installed tool dependency is unavailable")
|
|
33
|
+
module = importlib.util.module_from_spec(spec)
|
|
34
|
+
spec.loader.exec_module(module)
|
|
35
|
+
return module
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _named_regular(path, allow_missing=False):
|
|
39
|
+
try:
|
|
40
|
+
mode = os.lstat(path).st_mode
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
if allow_missing:
|
|
43
|
+
return False
|
|
44
|
+
raise
|
|
45
|
+
if not stat.S_ISREG(mode):
|
|
46
|
+
raise ValueError("not_named_regular_file")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sha256(data):
|
|
51
|
+
return hashlib.sha256(data).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _valid_risk(value):
|
|
55
|
+
return (
|
|
56
|
+
not isinstance(value, bool)
|
|
57
|
+
and isinstance(value, (int, float))
|
|
58
|
+
and math.isfinite(value)
|
|
59
|
+
and 0 <= value <= 1
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_existing(body, report_sha256, source_sha256, planner, report_path,
|
|
64
|
+
control_route, canary_percent, max_risk):
|
|
65
|
+
if not isinstance(body, dict) or set(body) != {
|
|
66
|
+
"observations", "report_sha256", "source_sha256", "items"
|
|
67
|
+
}:
|
|
68
|
+
raise ValueError("noncanonical_observations")
|
|
69
|
+
if body["observations"] != OBSERVATIONS:
|
|
70
|
+
raise ValueError("unsupported_observations_version")
|
|
71
|
+
if body["report_sha256"] != report_sha256 or body["source_sha256"] != source_sha256:
|
|
72
|
+
raise ValueError("binding_mismatch")
|
|
73
|
+
items = body["items"]
|
|
74
|
+
if not isinstance(items, list) or len(items) >= MAX_ITEMS:
|
|
75
|
+
raise ValueError("item_limit")
|
|
76
|
+
seen = set()
|
|
77
|
+
for item in items:
|
|
78
|
+
if not isinstance(item, dict) or set(item) != {
|
|
79
|
+
"subject", "assignment", "route", "accepted", "risk"
|
|
80
|
+
}:
|
|
81
|
+
raise ValueError("noncanonical_item")
|
|
82
|
+
subject = item["subject"]
|
|
83
|
+
if not isinstance(subject, str) or not subject.strip() or subject in seen:
|
|
84
|
+
raise ValueError("invalid_or_duplicate_subject")
|
|
85
|
+
seen.add(subject)
|
|
86
|
+
if not isinstance(item["accepted"], bool) or not _valid_risk(item["risk"]):
|
|
87
|
+
raise ValueError("invalid_measured_value")
|
|
88
|
+
plan = planner.plan(
|
|
89
|
+
report_path, subject, control_route, canary_percent, max_risk, True
|
|
90
|
+
)
|
|
91
|
+
if plan.get("refusal_reasons"):
|
|
92
|
+
raise ValueError("existing_item_cannot_be_rebound")
|
|
93
|
+
if item["assignment"] != plan.get("assignment") or item["route"] != plan.get("route"):
|
|
94
|
+
raise ValueError("existing_assignment_mismatch")
|
|
95
|
+
return items, seen
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _atomic_write(path, payload):
|
|
99
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
100
|
+
if not os.path.isdir(directory):
|
|
101
|
+
raise ValueError("parent_not_directory")
|
|
102
|
+
existing = _named_regular(path, allow_missing=True)
|
|
103
|
+
mode = stat.S_IMODE(os.lstat(path).st_mode) if existing else 0o600
|
|
104
|
+
temporary = None
|
|
105
|
+
try:
|
|
106
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", dir=directory)
|
|
107
|
+
os.fchmod(fd, mode)
|
|
108
|
+
with os.fdopen(fd, "wb") as handle:
|
|
109
|
+
handle.write(payload)
|
|
110
|
+
handle.flush()
|
|
111
|
+
os.fsync(handle.fileno())
|
|
112
|
+
os.replace(temporary, path)
|
|
113
|
+
temporary = None
|
|
114
|
+
directory_fd = os.open(directory, os.O_RDONLY)
|
|
115
|
+
try:
|
|
116
|
+
os.fsync(directory_fd)
|
|
117
|
+
finally:
|
|
118
|
+
os.close(directory_fd)
|
|
119
|
+
finally:
|
|
120
|
+
if temporary is not None:
|
|
121
|
+
try:
|
|
122
|
+
os.unlink(temporary)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record(report_path, observations_path, subject, accepted, risk, control_route,
|
|
128
|
+
canary_percent=10.0, max_risk=.25, enable_recording=False):
|
|
129
|
+
result = {
|
|
130
|
+
"record": DOMAIN,
|
|
131
|
+
"status": "REFUSED",
|
|
132
|
+
"assignment": None,
|
|
133
|
+
"route": None,
|
|
134
|
+
"observation_count": None,
|
|
135
|
+
"observations_sha256": None,
|
|
136
|
+
"refusal_reason": None,
|
|
137
|
+
}
|
|
138
|
+
if not enable_recording:
|
|
139
|
+
result["refusal_reason"] = "recording_not_enabled"
|
|
140
|
+
return result
|
|
141
|
+
if not isinstance(subject, str) or not subject.strip():
|
|
142
|
+
result["refusal_reason"] = "invalid_subject"
|
|
143
|
+
return result
|
|
144
|
+
if not isinstance(accepted, bool) or not _valid_risk(risk):
|
|
145
|
+
result["refusal_reason"] = "invalid_measured_value"
|
|
146
|
+
return result
|
|
147
|
+
try:
|
|
148
|
+
_named_regular(report_path)
|
|
149
|
+
if os.lstat(report_path).st_size > MAX_BYTES:
|
|
150
|
+
raise ValueError("report_too_large")
|
|
151
|
+
planner = _load_tool("outcome-canary.py", "outcome_canary_observe_planner")
|
|
152
|
+
evaluator = _load_tool("outcome-canary-evaluate.py", "outcome_canary_observe_evaluator")
|
|
153
|
+
plan = planner.plan(
|
|
154
|
+
report_path, subject, control_route, canary_percent, max_risk, True
|
|
155
|
+
)
|
|
156
|
+
if plan.get("source_missing"):
|
|
157
|
+
result["refusal_reason"] = "source_missing"
|
|
158
|
+
return result
|
|
159
|
+
if plan.get("refusal_reasons"):
|
|
160
|
+
result["refusal_reason"] = "plan_refused"
|
|
161
|
+
return result
|
|
162
|
+
report_sha256 = plan["report_sha256"]
|
|
163
|
+
source_sha256 = plan["source_sha256"]
|
|
164
|
+
|
|
165
|
+
lock_path = os.path.abspath(observations_path) + ".lock"
|
|
166
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
167
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
168
|
+
flags |= os.O_NOFOLLOW
|
|
169
|
+
lock_fd = os.open(lock_path, flags, 0o600)
|
|
170
|
+
try:
|
|
171
|
+
if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
|
|
172
|
+
raise ValueError("invalid_lock")
|
|
173
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
174
|
+
if _named_regular(observations_path, allow_missing=True):
|
|
175
|
+
body, _ = evaluator._read_json(observations_path)
|
|
176
|
+
items, seen = _validate_existing(
|
|
177
|
+
body, report_sha256, source_sha256, planner, report_path,
|
|
178
|
+
control_route, canary_percent, max_risk,
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
body = {
|
|
182
|
+
"observations": OBSERVATIONS,
|
|
183
|
+
"report_sha256": report_sha256,
|
|
184
|
+
"source_sha256": source_sha256,
|
|
185
|
+
"items": [],
|
|
186
|
+
}
|
|
187
|
+
items, seen = body["items"], set()
|
|
188
|
+
if len(items) >= MAX_ITEMS:
|
|
189
|
+
raise ValueError("item_limit")
|
|
190
|
+
if subject in seen:
|
|
191
|
+
raise ValueError("duplicate_subject")
|
|
192
|
+
items.append({
|
|
193
|
+
"subject": subject,
|
|
194
|
+
"assignment": plan["assignment"],
|
|
195
|
+
"route": plan["route"],
|
|
196
|
+
"accepted": accepted,
|
|
197
|
+
"risk": float(risk),
|
|
198
|
+
})
|
|
199
|
+
payload = (json.dumps(body, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
200
|
+
if len(payload) > MAX_BYTES:
|
|
201
|
+
raise ValueError("observations_too_large")
|
|
202
|
+
_atomic_write(observations_path, payload)
|
|
203
|
+
finally:
|
|
204
|
+
os.close(lock_fd)
|
|
205
|
+
result.update({
|
|
206
|
+
"status": "RECORDED",
|
|
207
|
+
"assignment": plan["assignment"],
|
|
208
|
+
"route": plan["route"],
|
|
209
|
+
"observation_count": len(items),
|
|
210
|
+
"observations_sha256": _sha256(payload),
|
|
211
|
+
})
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
result["refusal_reason"] = "input_missing"
|
|
214
|
+
except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
215
|
+
reason = str(exc)
|
|
216
|
+
allowed = {
|
|
217
|
+
"not_named_regular_file", "noncanonical_observations",
|
|
218
|
+
"unsupported_observations_version", "binding_mismatch", "item_limit",
|
|
219
|
+
"noncanonical_item", "invalid_or_duplicate_subject", "invalid_measured_value",
|
|
220
|
+
"existing_item_cannot_be_rebound", "existing_assignment_mismatch",
|
|
221
|
+
"parent_not_directory", "report_too_large", "source_missing", "invalid_lock",
|
|
222
|
+
"duplicate_subject", "observations_too_large",
|
|
223
|
+
}
|
|
224
|
+
result["refusal_reason"] = reason if reason in allowed else "unsafe_or_malformed_input"
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv=None):
|
|
229
|
+
parser = Parser(prog="outcome-canary-observe")
|
|
230
|
+
parser.add_argument("report")
|
|
231
|
+
parser.add_argument("observations")
|
|
232
|
+
parser.add_argument("--enable-recording", action="store_true")
|
|
233
|
+
parser.add_argument("--subject", required=True)
|
|
234
|
+
accepted = parser.add_mutually_exclusive_group(required=True)
|
|
235
|
+
accepted.add_argument("--accepted", action="store_true")
|
|
236
|
+
accepted.add_argument("--rejected", action="store_true")
|
|
237
|
+
parser.add_argument("--risk", type=float, required=True)
|
|
238
|
+
parser.add_argument("--control-route", required=True)
|
|
239
|
+
parser.add_argument("--canary-percent", type=float, default=10.0)
|
|
240
|
+
parser.add_argument("--max-risk", type=float, default=.25)
|
|
241
|
+
parser.add_argument("--json", action="store_true")
|
|
242
|
+
args = parser.parse_args(argv)
|
|
243
|
+
if not os.path.lexists(args.report):
|
|
244
|
+
print("outcome-canary-observe: input missing", file=sys.stderr)
|
|
245
|
+
return NO_INPUT
|
|
246
|
+
result = record(
|
|
247
|
+
args.report, args.observations, args.subject, args.accepted, args.risk,
|
|
248
|
+
args.control_route, args.canary_percent, args.max_risk, args.enable_recording,
|
|
249
|
+
)
|
|
250
|
+
if args.json:
|
|
251
|
+
print(json.dumps(result, sort_keys=True))
|
|
252
|
+
elif result["status"] == "RECORDED":
|
|
253
|
+
print(f"Canary observation: RECORDED ({result['assignment']} -> {result['route']})")
|
|
254
|
+
print(f" observations={result['observation_count']} sha256={result['observations_sha256']}")
|
|
255
|
+
else:
|
|
256
|
+
print(f"Canary observation: REFUSED ({result['refusal_reason']})")
|
|
257
|
+
return OK if result["status"] == "RECORDED" else REFUSED
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Independently verify a canary decision receipt against its exact evidence."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
OK, REFUSED, USAGE, NO_INPUT = 0, 3, 64, 66
|
|
12
|
+
DOMAIN = "loki-outcome-canary-decision-receipt/v1"
|
|
13
|
+
VERDICTS = {"PROMOTE", "HOLD", "ROLLBACK"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Parser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, message):
|
|
18
|
+
self.print_usage(sys.stderr)
|
|
19
|
+
self.exit(USAGE, f"{self.prog}: error: {message}\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_tool(filename, module_name):
|
|
23
|
+
path = pathlib.Path(__file__).with_name(filename)
|
|
24
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
25
|
+
if spec is None or spec.loader is None:
|
|
26
|
+
raise RuntimeError("installed tool dependency is unavailable")
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sha256(payload):
|
|
33
|
+
return hashlib.sha256(payload).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _expected_receipt(decision):
|
|
37
|
+
return {
|
|
38
|
+
"receipt": DOMAIN,
|
|
39
|
+
"evaluation": decision["evaluation"],
|
|
40
|
+
"report_sha256": decision["report_sha256"],
|
|
41
|
+
"source_sha256": decision["source_sha256"],
|
|
42
|
+
"observations_sha256": decision["observations_sha256"],
|
|
43
|
+
"policy": {
|
|
44
|
+
"control_route": decision["control_route"],
|
|
45
|
+
"canary_route": decision["canary_route"],
|
|
46
|
+
"canary_percent": decision["canary_percent"],
|
|
47
|
+
"max_risk": decision["max_risk"],
|
|
48
|
+
"min_samples": decision["min_samples"],
|
|
49
|
+
"min_lift_bps": decision["min_lift_bps"],
|
|
50
|
+
},
|
|
51
|
+
"control": decision["control"],
|
|
52
|
+
"canary": decision["canary"],
|
|
53
|
+
"accepted_delta_bps": decision["accepted_delta_bps"],
|
|
54
|
+
"verdict": decision["verdict"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def verify_receipt(report_path, observations_path, receipt_path,
|
|
59
|
+
enable_verification=False):
|
|
60
|
+
"""Recompute the decision and compare its canonical receipt byte-for-byte."""
|
|
61
|
+
result = {
|
|
62
|
+
"receipt": DOMAIN,
|
|
63
|
+
"status": "REFUSED",
|
|
64
|
+
"verdict": None,
|
|
65
|
+
"receipt_sha256": None,
|
|
66
|
+
"refusal_reason": None,
|
|
67
|
+
}
|
|
68
|
+
if not enable_verification:
|
|
69
|
+
result["refusal_reason"] = "verification_not_enabled"
|
|
70
|
+
return result
|
|
71
|
+
try:
|
|
72
|
+
creator = _load_tool(
|
|
73
|
+
"outcome-canary-receipt.py", "outcome_canary_receipt_verifier_creator"
|
|
74
|
+
)
|
|
75
|
+
evaluator = creator._load_tool(
|
|
76
|
+
"outcome-canary-evaluate.py", "outcome_canary_receipt_verifier_evaluator"
|
|
77
|
+
)
|
|
78
|
+
receipt_bytes = creator._read_named_regular(receipt_path)
|
|
79
|
+
result["receipt_sha256"] = _sha256(receipt_bytes)
|
|
80
|
+
body = json.loads(
|
|
81
|
+
receipt_bytes.decode("utf-8"), object_pairs_hook=evaluator._object
|
|
82
|
+
)
|
|
83
|
+
required = {
|
|
84
|
+
"receipt", "evaluation", "report_sha256", "source_sha256",
|
|
85
|
+
"observations_sha256", "policy", "control", "canary",
|
|
86
|
+
"accepted_delta_bps", "verdict",
|
|
87
|
+
}
|
|
88
|
+
if not isinstance(body, dict) or set(body) != required or body.get("receipt") != DOMAIN:
|
|
89
|
+
raise ValueError("receipt_schema_invalid")
|
|
90
|
+
canonical = (
|
|
91
|
+
json.dumps(body, sort_keys=True, separators=(",", ":")) + "\n"
|
|
92
|
+
).encode()
|
|
93
|
+
if receipt_bytes != canonical:
|
|
94
|
+
raise ValueError("receipt_noncanonical")
|
|
95
|
+
policy = body.get("policy")
|
|
96
|
+
policy_fields = {
|
|
97
|
+
"control_route", "canary_route", "canary_percent", "max_risk",
|
|
98
|
+
"min_samples", "min_lift_bps",
|
|
99
|
+
}
|
|
100
|
+
if not isinstance(policy, dict) or set(policy) != policy_fields:
|
|
101
|
+
raise ValueError("receipt_policy_invalid")
|
|
102
|
+
|
|
103
|
+
decision = evaluator.evaluate(
|
|
104
|
+
report_path,
|
|
105
|
+
observations_path,
|
|
106
|
+
policy["control_route"],
|
|
107
|
+
policy["canary_percent"],
|
|
108
|
+
policy["max_risk"],
|
|
109
|
+
policy["min_samples"],
|
|
110
|
+
policy["min_lift_bps"],
|
|
111
|
+
True,
|
|
112
|
+
)
|
|
113
|
+
if decision.get("refusal_reasons") or decision.get("verdict") not in VERDICTS:
|
|
114
|
+
result["refusal_reason"] = "evaluation_refused"
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
# Bind the recomputation to stable exact bytes, including the report's source.
|
|
118
|
+
report_bytes = creator._read_named_regular(report_path)
|
|
119
|
+
observations_bytes = creator._read_named_regular(observations_path)
|
|
120
|
+
if _sha256(report_bytes) != decision["report_sha256"] or (
|
|
121
|
+
_sha256(observations_bytes) != decision["observations_sha256"]
|
|
122
|
+
):
|
|
123
|
+
result["refusal_reason"] = "evidence_drift"
|
|
124
|
+
return result
|
|
125
|
+
report = json.loads(
|
|
126
|
+
report_bytes.decode("utf-8"), object_pairs_hook=evaluator._object
|
|
127
|
+
)
|
|
128
|
+
source = report.get("source") if isinstance(report, dict) else None
|
|
129
|
+
if not isinstance(source, str) or not source.strip():
|
|
130
|
+
result["refusal_reason"] = "source_unsafe_or_drifted"
|
|
131
|
+
return result
|
|
132
|
+
if _sha256(creator._read_named_regular(source)) != decision["source_sha256"]:
|
|
133
|
+
result["refusal_reason"] = "source_unsafe_or_drifted"
|
|
134
|
+
return result
|
|
135
|
+
if body != _expected_receipt(decision):
|
|
136
|
+
result["refusal_reason"] = "receipt_mismatch"
|
|
137
|
+
return result
|
|
138
|
+
if creator._read_named_regular(receipt_path) != receipt_bytes:
|
|
139
|
+
result["refusal_reason"] = "evidence_drift"
|
|
140
|
+
return result
|
|
141
|
+
result.update(status="VERIFIED", verdict=decision["verdict"])
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
result["refusal_reason"] = "input_missing"
|
|
144
|
+
except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
145
|
+
reason = str(exc)
|
|
146
|
+
allowed = {
|
|
147
|
+
"evidence_drift", "input_too_large", "not_named_regular_file",
|
|
148
|
+
"receipt_schema_invalid", "receipt_noncanonical", "receipt_policy_invalid",
|
|
149
|
+
"source_unsafe_or_drifted",
|
|
150
|
+
}
|
|
151
|
+
result["refusal_reason"] = (
|
|
152
|
+
reason if reason in allowed else "unsafe_or_malformed_input"
|
|
153
|
+
)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv=None):
|
|
158
|
+
parser = Parser(prog="outcome-canary-receipt-verify")
|
|
159
|
+
parser.add_argument("report")
|
|
160
|
+
parser.add_argument("observations")
|
|
161
|
+
parser.add_argument("receipt")
|
|
162
|
+
parser.add_argument("--enable-verification", action="store_true")
|
|
163
|
+
parser.add_argument("--json", action="store_true")
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
for path in (args.report, args.observations, args.receipt):
|
|
166
|
+
if not os.path.lexists(path):
|
|
167
|
+
print("outcome-canary-receipt-verify: input missing", file=sys.stderr)
|
|
168
|
+
return NO_INPUT
|
|
169
|
+
result = verify_receipt(
|
|
170
|
+
args.report, args.observations, args.receipt, args.enable_verification
|
|
171
|
+
)
|
|
172
|
+
if args.json:
|
|
173
|
+
print(json.dumps(result, sort_keys=True))
|
|
174
|
+
elif result["status"] == "VERIFIED":
|
|
175
|
+
print(f"Canary decision receipt: VERIFIED ({result['verdict']})")
|
|
176
|
+
print(f" sha256={result['receipt_sha256']}")
|
|
177
|
+
else:
|
|
178
|
+
print(f"Canary decision receipt: REFUSED ({result['refusal_reason']})")
|
|
179
|
+
return OK if result["status"] == "VERIFIED" else REFUSED
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create one portable, source-bound canary decision receipt."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
OK, REFUSED, USAGE, NO_INPUT = 0, 3, 64, 66
|
|
14
|
+
DOMAIN = "loki-outcome-canary-decision-receipt/v1"
|
|
15
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Parser(argparse.ArgumentParser):
|
|
19
|
+
def error(self, message):
|
|
20
|
+
self.print_usage(sys.stderr)
|
|
21
|
+
self.exit(USAGE, f"{self.prog}: error: {message}\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_tool(filename, module_name):
|
|
25
|
+
path = pathlib.Path(__file__).with_name(filename)
|
|
26
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
27
|
+
if spec is None or spec.loader is None:
|
|
28
|
+
raise RuntimeError("installed tool dependency is unavailable")
|
|
29
|
+
module = importlib.util.module_from_spec(spec)
|
|
30
|
+
spec.loader.exec_module(module)
|
|
31
|
+
return module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sha256(data):
|
|
35
|
+
return hashlib.sha256(data).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_named_regular(path):
|
|
39
|
+
flags = os.O_RDONLY
|
|
40
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
41
|
+
flags |= os.O_NOFOLLOW
|
|
42
|
+
descriptor = os.open(path, flags)
|
|
43
|
+
try:
|
|
44
|
+
before = os.fstat(descriptor)
|
|
45
|
+
if not stat.S_ISREG(before.st_mode):
|
|
46
|
+
raise ValueError("not_named_regular_file")
|
|
47
|
+
chunks = []
|
|
48
|
+
size = 0
|
|
49
|
+
while True:
|
|
50
|
+
chunk = os.read(descriptor, min(65536, MAX_BYTES + 1 - size))
|
|
51
|
+
if not chunk:
|
|
52
|
+
break
|
|
53
|
+
chunks.append(chunk)
|
|
54
|
+
size += len(chunk)
|
|
55
|
+
if size > MAX_BYTES:
|
|
56
|
+
raise ValueError("input_too_large")
|
|
57
|
+
after = os.fstat(descriptor)
|
|
58
|
+
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
|
|
59
|
+
after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("evidence_drift")
|
|
62
|
+
return b"".join(chunks)
|
|
63
|
+
finally:
|
|
64
|
+
os.close(descriptor)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_is_absent(path):
|
|
68
|
+
try:
|
|
69
|
+
os.lstat(path)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return
|
|
72
|
+
raise ValueError("target_exists")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _publish_create_only(path, payload):
|
|
76
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
77
|
+
if not os.path.isdir(directory):
|
|
78
|
+
raise ValueError("parent_not_directory")
|
|
79
|
+
_target_is_absent(path)
|
|
80
|
+
temporary = None
|
|
81
|
+
try:
|
|
82
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
83
|
+
prefix=f".{os.path.basename(path)}.", dir=directory
|
|
84
|
+
)
|
|
85
|
+
os.fchmod(descriptor, 0o600)
|
|
86
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
87
|
+
handle.write(payload)
|
|
88
|
+
handle.flush()
|
|
89
|
+
os.fsync(handle.fileno())
|
|
90
|
+
# Hard-link publication is atomic and refuses a target created concurrently.
|
|
91
|
+
os.link(temporary, path)
|
|
92
|
+
os.unlink(temporary)
|
|
93
|
+
temporary = None
|
|
94
|
+
directory_fd = os.open(directory, os.O_RDONLY)
|
|
95
|
+
try:
|
|
96
|
+
os.fsync(directory_fd)
|
|
97
|
+
finally:
|
|
98
|
+
os.close(directory_fd)
|
|
99
|
+
finally:
|
|
100
|
+
if temporary is not None:
|
|
101
|
+
try:
|
|
102
|
+
os.unlink(temporary)
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def create_receipt(report_path, observations_path, receipt_path, control_route,
|
|
108
|
+
canary_percent=10.0, max_risk=.25, min_samples=5,
|
|
109
|
+
min_lift_bps=1, enable_receipt=False):
|
|
110
|
+
"""Reverify a decision and publish one immutable portable receipt."""
|
|
111
|
+
result = {
|
|
112
|
+
"receipt": DOMAIN,
|
|
113
|
+
"status": "REFUSED",
|
|
114
|
+
"verdict": None,
|
|
115
|
+
"receipt_sha256": None,
|
|
116
|
+
"refusal_reason": None,
|
|
117
|
+
}
|
|
118
|
+
if not enable_receipt:
|
|
119
|
+
result["refusal_reason"] = "receipt_not_enabled"
|
|
120
|
+
return result
|
|
121
|
+
try:
|
|
122
|
+
_target_is_absent(receipt_path)
|
|
123
|
+
evaluator = _load_tool(
|
|
124
|
+
"outcome-canary-evaluate.py", "outcome_canary_receipt_evaluator"
|
|
125
|
+
)
|
|
126
|
+
decision = evaluator.evaluate(
|
|
127
|
+
report_path, observations_path, control_route, canary_percent,
|
|
128
|
+
max_risk, min_samples, min_lift_bps, True,
|
|
129
|
+
)
|
|
130
|
+
if decision.get("refusal_reasons") or decision.get("verdict") not in {
|
|
131
|
+
"PROMOTE", "HOLD", "ROLLBACK"
|
|
132
|
+
}:
|
|
133
|
+
result["refusal_reason"] = "evaluation_refused"
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
report_bytes = _read_named_regular(report_path)
|
|
137
|
+
observations_bytes = _read_named_regular(observations_path)
|
|
138
|
+
if _sha256(report_bytes) != decision["report_sha256"] or (
|
|
139
|
+
_sha256(observations_bytes) != decision["observations_sha256"]
|
|
140
|
+
):
|
|
141
|
+
result["refusal_reason"] = "evidence_drift"
|
|
142
|
+
return result
|
|
143
|
+
report = json.loads(report_bytes.decode("utf-8"), object_pairs_hook=evaluator._object)
|
|
144
|
+
source = report.get("source") if isinstance(report, dict) else None
|
|
145
|
+
if not isinstance(source, str) or not source.strip():
|
|
146
|
+
result["refusal_reason"] = "source_unsafe_or_drifted"
|
|
147
|
+
return result
|
|
148
|
+
source_bytes = _read_named_regular(source)
|
|
149
|
+
if _sha256(source_bytes) != decision["source_sha256"]:
|
|
150
|
+
result["refusal_reason"] = "source_unsafe_or_drifted"
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
receipt = {
|
|
154
|
+
"receipt": DOMAIN,
|
|
155
|
+
"evaluation": decision["evaluation"],
|
|
156
|
+
"report_sha256": decision["report_sha256"],
|
|
157
|
+
"source_sha256": decision["source_sha256"],
|
|
158
|
+
"observations_sha256": decision["observations_sha256"],
|
|
159
|
+
"policy": {
|
|
160
|
+
"control_route": decision["control_route"],
|
|
161
|
+
"canary_route": decision["canary_route"],
|
|
162
|
+
"canary_percent": decision["canary_percent"],
|
|
163
|
+
"max_risk": decision["max_risk"],
|
|
164
|
+
"min_samples": decision["min_samples"],
|
|
165
|
+
"min_lift_bps": decision["min_lift_bps"],
|
|
166
|
+
},
|
|
167
|
+
"control": decision["control"],
|
|
168
|
+
"canary": decision["canary"],
|
|
169
|
+
"accepted_delta_bps": decision["accepted_delta_bps"],
|
|
170
|
+
"verdict": decision["verdict"],
|
|
171
|
+
}
|
|
172
|
+
payload = (json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
173
|
+
if len(payload) > MAX_BYTES:
|
|
174
|
+
raise ValueError("receipt_too_large")
|
|
175
|
+
# Recheck all evidence immediately before irreversible create-only publication.
|
|
176
|
+
if _sha256(_read_named_regular(report_path)) != decision["report_sha256"] or (
|
|
177
|
+
_sha256(_read_named_regular(observations_path)) != decision["observations_sha256"]
|
|
178
|
+
) or _sha256(_read_named_regular(source)) != decision["source_sha256"]:
|
|
179
|
+
result["refusal_reason"] = "evidence_drift"
|
|
180
|
+
return result
|
|
181
|
+
_publish_create_only(receipt_path, payload)
|
|
182
|
+
result.update({
|
|
183
|
+
"status": "RECORDED",
|
|
184
|
+
"verdict": decision["verdict"],
|
|
185
|
+
"receipt_sha256": _sha256(payload),
|
|
186
|
+
})
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
result["refusal_reason"] = "input_missing"
|
|
189
|
+
except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
190
|
+
reason = str(exc)
|
|
191
|
+
allowed = {
|
|
192
|
+
"target_exists", "parent_not_directory", "evidence_drift",
|
|
193
|
+
"not_named_regular_file", "input_too_large", "receipt_too_large",
|
|
194
|
+
"source_unsafe_or_drifted",
|
|
195
|
+
}
|
|
196
|
+
if isinstance(exc, FileExistsError):
|
|
197
|
+
reason = "target_exists"
|
|
198
|
+
result["refusal_reason"] = reason if reason in allowed else "unsafe_or_malformed_input"
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv=None):
|
|
203
|
+
parser = Parser(prog="outcome-canary-receipt")
|
|
204
|
+
parser.add_argument("report")
|
|
205
|
+
parser.add_argument("observations")
|
|
206
|
+
parser.add_argument("receipt")
|
|
207
|
+
parser.add_argument("--enable-receipt", action="store_true")
|
|
208
|
+
parser.add_argument("--control-route", required=True)
|
|
209
|
+
parser.add_argument("--canary-percent", type=float, default=10.0)
|
|
210
|
+
parser.add_argument("--max-risk", type=float, default=.25)
|
|
211
|
+
parser.add_argument("--min-samples", type=int, default=5)
|
|
212
|
+
parser.add_argument("--min-lift-bps", type=int, default=1)
|
|
213
|
+
parser.add_argument("--json", action="store_true")
|
|
214
|
+
args = parser.parse_args(argv)
|
|
215
|
+
for path in (args.report, args.observations):
|
|
216
|
+
if not os.path.lexists(path):
|
|
217
|
+
print("outcome-canary-receipt: input missing", file=sys.stderr)
|
|
218
|
+
return NO_INPUT
|
|
219
|
+
result = create_receipt(
|
|
220
|
+
args.report, args.observations, args.receipt, args.control_route,
|
|
221
|
+
args.canary_percent, args.max_risk, args.min_samples,
|
|
222
|
+
args.min_lift_bps, args.enable_receipt,
|
|
223
|
+
)
|
|
224
|
+
if args.json:
|
|
225
|
+
print(json.dumps(result, sort_keys=True))
|
|
226
|
+
elif result["status"] == "RECORDED":
|
|
227
|
+
print(f"Canary decision receipt: RECORDED ({result['verdict']})")
|
|
228
|
+
print(f" sha256={result['receipt_sha256']}")
|
|
229
|
+
else:
|
|
230
|
+
print(f"Canary decision receipt: REFUSED ({result['refusal_reason']})")
|
|
231
|
+
return OK if result["status"] == "RECORDED" else REFUSED
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
raise SystemExit(main())
|