loki-mode 9.28.1 → 9.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +10 -2
- package/docs/AUDIT-CHAIN-THREAT-MODEL.md +134 -0
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/src/audit/crosslink.js +120 -1
- package/src/audit/subscriber.js +61 -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.
|
|
6
|
+
# Loki Mode v9.30.0
|
|
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.
|
|
473
|
+
**v9.30.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
9.
|
|
1
|
+
9.30.0
|
package/dashboard/__init__.py
CHANGED
package/dashboard/audit.py
CHANGED
|
@@ -194,8 +194,16 @@ def _ensure_audit_dir() -> None:
|
|
|
194
194
|
def _compute_chain_hash(entry_json: str, prev_hash: str) -> str:
|
|
195
195
|
"""Compute a SHA-256 chain hash linking this entry to the previous one.
|
|
196
196
|
|
|
197
|
-
Each hash depends on the previous entry's hash,
|
|
198
|
-
|
|
197
|
+
Each hash depends on the previous entry's hash, so modifying an entry
|
|
198
|
+
invalidates every hash after it. That detects corruption and truncation.
|
|
199
|
+
|
|
200
|
+
It is NOT tamper-proof. This hash is unkeyed and the genesis is a constant
|
|
201
|
+
("0" * 64), so every input is available to anyone who can write the file:
|
|
202
|
+
an attacker can recompute a fully consistent chain over invented history.
|
|
203
|
+
Reproduced in docs/AUDIT-CHAIN-THREAT-MODEL.md. Closing it requires
|
|
204
|
+
something the writer cannot reproduce (an external witness, or a signature
|
|
205
|
+
over the tip with an off-machine key). Do not describe the output of this
|
|
206
|
+
function as tamper-proof.
|
|
199
207
|
"""
|
|
200
208
|
return hashlib.sha256((prev_hash + entry_json).encode("utf-8")).hexdigest()
|
|
201
209
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# What the audit hash chain does and does not prove
|
|
2
|
+
|
|
3
|
+
Loki's wedge is a receipt the buyer verifies without trusting us. That claim is
|
|
4
|
+
only worth making if we are precise about what the current chain actually
|
|
5
|
+
proves. This document states the limit, with a reproduction, because a
|
|
6
|
+
tamper-evidence claim that does not hold is worse than no claim: a buyer may
|
|
7
|
+
rely on it.
|
|
8
|
+
|
|
9
|
+
## Measured: the chain is re-forgeable
|
|
10
|
+
|
|
11
|
+
`src/audit/log.js` computes each entry hash as an unkeyed SHA-256 over public
|
|
12
|
+
fields, with a constant genesis:
|
|
13
|
+
|
|
14
|
+
- genesis is the literal string `GENESIS` (`src/audit/log.js:16`)
|
|
15
|
+
- `_computeHash` (`:127-134`) hashes `{seq,timestamp,who,what,where,why,metadata,previousHash}`
|
|
16
|
+
- every input to that hash is present in the file the attacker is editing
|
|
17
|
+
|
|
18
|
+
Nothing in the recipe is secret, so anyone who can write the log can recompute a
|
|
19
|
+
complete, internally consistent chain over invented history. Reproduced on
|
|
20
|
+
v9.28.1:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
honest verify : {"valid":true,"entries":2,"brokenAt":null,"error":null}
|
|
24
|
+
forged verify : {"valid":true,"entries":2,"brokenAt":null,"error":null}
|
|
25
|
+
forged contents : NEVER HAPPENED | ALSO FORGED
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The history was replaced wholesale and `verifyChain()` reported `valid: true`.
|
|
29
|
+
|
|
30
|
+
### The same holds for the dashboard chain
|
|
31
|
+
|
|
32
|
+
`dashboard/audit.py` is a separate implementation with the same property:
|
|
33
|
+
genesis is the constant `"0" * 64` (`:58`, `:115`), and `_compute_chain_hash`
|
|
34
|
+
(`:194-200`) is an unkeyed `sha256(prev_hash + entry_json)`. Its own docstring
|
|
35
|
+
at `:197` calls the result tamper-evident. Forged with the writer's exact recipe
|
|
36
|
+
(`json.dumps(entry, sort_keys=True, default=str)`, field `_integrity_hash`,
|
|
37
|
+
`:369-371`), `verify_log_integrity` reports:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
forged verdict: {'valid': True, 'entries_checked': 2, 'first_tampered_line': None, ...}
|
|
41
|
+
contents : NEVER HAPPENED | ALSO FORGED
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
One methodological note, because it nearly produced a false all-clear here: a
|
|
45
|
+
first forgery attempt using a guessed field name returned `valid: False` with
|
|
46
|
+
`entries_checked: 0`. That was the verifier rejecting the probe's SCHEMA, not
|
|
47
|
+
detecting tampering. A failed exploit can mean the probe was wrong rather than
|
|
48
|
+
the target is sound, and `entries_checked: 0` is the tell. Any future test of
|
|
49
|
+
this property must assert that entries were actually checked.
|
|
50
|
+
|
|
51
|
+
## What this means
|
|
52
|
+
|
|
53
|
+
The chain detects **accidental corruption and truncation**: a partial write, a
|
|
54
|
+
dropped line, a byte flip. Those are real failure modes and catching them has
|
|
55
|
+
value.
|
|
56
|
+
|
|
57
|
+
It does **not** detect a deliberate rewrite by anyone with write access to the
|
|
58
|
+
log, which is the threat an audit trail exists to address. Under the standard
|
|
59
|
+
audit-log threat model the writer is the adversary, and an unkeyed chain the
|
|
60
|
+
writer can recompute offers that adversary no obstacle.
|
|
61
|
+
|
|
62
|
+
Note the failure direction. A broken chain is strong evidence of a problem. An
|
|
63
|
+
intact chain is NOT evidence of integrity, because it is exactly what both an
|
|
64
|
+
honest run and a competent forgery produce. Absence of evidence is not evidence
|
|
65
|
+
of absence.
|
|
66
|
+
|
|
67
|
+
## What would actually close it
|
|
68
|
+
|
|
69
|
+
Tamper-evidence requires something the log's writer cannot reproduce. In
|
|
70
|
+
increasing order of strength:
|
|
71
|
+
|
|
72
|
+
1. **A keyed MAC** with a key the agent cannot read. Moves the problem to key
|
|
73
|
+
custody; on a developer laptop where the agent runs as the user, there is
|
|
74
|
+
often nowhere to put such a key that the agent cannot reach.
|
|
75
|
+
2. **An external witness**: periodically pin the chain tip somewhere append-only
|
|
76
|
+
that the agent cannot rewrite. `writeWitness` already exists at
|
|
77
|
+
`src/audit/crosslink.js:234` and has zero production callers.
|
|
78
|
+
3. **A signature over the tip** with a key held off the machine.
|
|
79
|
+
|
|
80
|
+
Only 2 and 3 survive an adversary who controls the machine, which is the case
|
|
81
|
+
that matters for a receipt a third party is meant to trust.
|
|
82
|
+
|
|
83
|
+
## What is closed as of v9.30.0
|
|
84
|
+
|
|
85
|
+
`writeWitness` is now wired and, more importantly, **reconciled**. The gap was
|
|
86
|
+
not only that nothing called it: `verifyUnified` called `verifyWitnessFile`,
|
|
87
|
+
which checks the witness file's own monotonicity and never compares a witnessed
|
|
88
|
+
tip to the live chain. Measured before the fix, with a witness file present and
|
|
89
|
+
valid:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
witnessed tip : 337d47ce70... live tip now : d376cdd97d...
|
|
93
|
+
verifyUnified : {"valid":true, "witness":{"present":true,"valid":true}}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A witness nobody reconciles is not a control. `reconcileWitnessedPrefix`
|
|
97
|
+
(`src/audit/crosslink.js`) now compares each witnessed tip against the chain
|
|
98
|
+
entry at that position, and `verifyUnified` folds the result into its verdict.
|
|
99
|
+
The same forgery now returns `valid:false` with the entry named.
|
|
100
|
+
|
|
101
|
+
The comparison is prefix-based rather than tip-equality on purpose: a chain
|
|
102
|
+
legitimately grows after a witness, so requiring the tips to match would fire on
|
|
103
|
+
normal operation, and a guard that fires on normal operation gets turned off.
|
|
104
|
+
Both directions are mutation-tested.
|
|
105
|
+
|
|
106
|
+
The subscriber writes a witness at session end and every
|
|
107
|
+
`LOKI_AUDIT_WITNESS_INTERVAL_SEC` (default 300; `LOKI_AUDIT_WITNESS=0` opts out).
|
|
108
|
+
Periodic witnessing matters because a witness taken only at shutdown is lost to
|
|
109
|
+
SIGKILL, which is when the trail matters most.
|
|
110
|
+
|
|
111
|
+
**What is still open, precisely.** A local witness file is itself rewritable by
|
|
112
|
+
the same adversary. What the reconciliation buys is that forging now requires
|
|
113
|
+
rewriting the chain AND every witness consistently, rather than the chain alone.
|
|
114
|
+
That is a higher bar, not a closed door. The closed door is
|
|
115
|
+
`LOKI_AUDIT_WITNESS_COMMAND`, which ships the witness line off the machine to a
|
|
116
|
+
WORM mount or timestamping authority: an out-of-band copy is the only form that
|
|
117
|
+
survives an adversary who controls this host. It is off by default because it
|
|
118
|
+
needs infrastructure we cannot assume.
|
|
119
|
+
|
|
120
|
+
Two honest limits that remain:
|
|
121
|
+
|
|
122
|
+
- The subscriber is gated on `LOKI_AUDIT_ENABLED` (default false), so a default
|
|
123
|
+
install still writes no agent chain and no witness.
|
|
124
|
+
- Nothing yet witnesses on the Bun route.
|
|
125
|
+
|
|
126
|
+
## Current honest claim
|
|
127
|
+
|
|
128
|
+
Until a witness or off-machine signature is wired, the supportable claim is:
|
|
129
|
+
|
|
130
|
+
> The audit log is hash-chained, which detects corruption and truncation. It is
|
|
131
|
+
> not tamper-proof against an adversary with write access to the log.
|
|
132
|
+
|
|
133
|
+
Do not describe the current chain as tamper-proof, tamper-resistant, or as
|
|
134
|
+
evidence a third party can rely on for integrity against a motivated writer.
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v9.
|
|
5
|
+
**Version:** v9.30.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var St=Object.create;var{getPrototypeOf:yt,defineProperty:jG,getOwnPropertyNames:bt}=Object;var ft=Object.prototype.hasOwnProperty;function _t($){return this[$]}var vt,ht,gt=($,X,Q)=>{var z=$!=null&&typeof $==="object";if(z){var Z=X?vt??=new WeakMap:ht??=new WeakMap,K=Z.get($);if(K)return K}Q=$!=null?St(yt($)):{};let J=X||!$||!$.__esModule?jG(Q,"default",{value:$,enumerable:!0}):Q;for(let q of bt($))if(!ft.call(J,q))jG(J,q,{get:_t.bind($,q),enumerable:!0});if(z)Z.set($,J);return J};var zq=($,X)=>()=>(X||$((X={exports:{}}).exports,X),X.exports);var mt=($)=>$;function ut($,X){this[$]=mt.bind(null,X)}var B1=($,X)=>{for(var Q in X)jG($,Q,{get:X[Q],enumerable:!0,configurable:!0,set:ut.bind(X,Q)})};var s=($,X)=>()=>($&&(X=$($=0)),X);var w5=import.meta.require;var YR={};B1(YR,{lokiDir:()=>h0,homeLokiDir:()=>HQ,findRepoRootForVersion:()=>AG,REPO_ROOT:()=>L1});import{resolve as h2,dirname as LG}from"path";import{fileURLToPath as dt}from"url";import{existsSync as Zq}from"fs";import{homedir as pt}from"os";function ct(){let $=VR;for(let X=0;X<6;X++){if(Zq(h2($,"VERSION"))&&Zq(h2($,"autonomy/run.sh")))return $;let Q=LG($);if(Q===$)break;$=Q}return h2(VR,"..","..","..")}function AG($){let X=$;for(let Q=0;Q<6;Q++){if(Zq(h2(X,"VERSION"))&&Zq(h2(X,"autonomy/run.sh")))return X;let z=LG(X);if(z===X)break;X=z}return h2($,"..","..","..")}function h0(){return process.env.LOKI_DIR??h2(process.cwd(),".loki")}function HQ(){return h2(pt(),".loki")}var VR,L1;var k1=s(()=>{VR=LG(dt(import.meta.url));L1=ct()});import{readFileSync as lt}from"fs";import{resolve as it,dirname as at}from"path";import{fileURLToPath as ot}from"url";function j9(){if(n3!==null)return n3;let $="9.
|
|
2
|
+
var St=Object.create;var{getPrototypeOf:yt,defineProperty:jG,getOwnPropertyNames:bt}=Object;var ft=Object.prototype.hasOwnProperty;function _t($){return this[$]}var vt,ht,gt=($,X,Q)=>{var z=$!=null&&typeof $==="object";if(z){var Z=X?vt??=new WeakMap:ht??=new WeakMap,K=Z.get($);if(K)return K}Q=$!=null?St(yt($)):{};let J=X||!$||!$.__esModule?jG(Q,"default",{value:$,enumerable:!0}):Q;for(let q of bt($))if(!ft.call(J,q))jG(J,q,{get:_t.bind($,q),enumerable:!0});if(z)Z.set($,J);return J};var zq=($,X)=>()=>(X||$((X={exports:{}}).exports,X),X.exports);var mt=($)=>$;function ut($,X){this[$]=mt.bind(null,X)}var B1=($,X)=>{for(var Q in X)jG($,Q,{get:X[Q],enumerable:!0,configurable:!0,set:ut.bind(X,Q)})};var s=($,X)=>()=>($&&(X=$($=0)),X);var w5=import.meta.require;var YR={};B1(YR,{lokiDir:()=>h0,homeLokiDir:()=>HQ,findRepoRootForVersion:()=>AG,REPO_ROOT:()=>L1});import{resolve as h2,dirname as LG}from"path";import{fileURLToPath as dt}from"url";import{existsSync as Zq}from"fs";import{homedir as pt}from"os";function ct(){let $=VR;for(let X=0;X<6;X++){if(Zq(h2($,"VERSION"))&&Zq(h2($,"autonomy/run.sh")))return $;let Q=LG($);if(Q===$)break;$=Q}return h2(VR,"..","..","..")}function AG($){let X=$;for(let Q=0;Q<6;Q++){if(Zq(h2(X,"VERSION"))&&Zq(h2(X,"autonomy/run.sh")))return X;let z=LG(X);if(z===X)break;X=z}return h2($,"..","..","..")}function h0(){return process.env.LOKI_DIR??h2(process.cwd(),".loki")}function HQ(){return h2(pt(),".loki")}var VR,L1;var k1=s(()=>{VR=LG(dt(import.meta.url));L1=ct()});import{readFileSync as lt}from"fs";import{resolve as it,dirname as at}from"path";import{fileURLToPath as ot}from"url";function j9(){if(n3!==null)return n3;let $="9.30.0";if(typeof $==="string"&&$.length>0)return n3=$,n3;try{let X=at(ot(import.meta.url)),Q=AG(X);n3=lt(it(Q,"VERSION"),"utf-8").trim()}catch{n3="unknown"}return n3}var n3=null;var Kq=s(()=>{k1()});var GR={};B1(GR,{runOrThrow:()=>Ue,run:()=>$1,readStreamCapped:()=>Jq,commandVersion:()=>We,commandExists:()=>g5,ShellError:()=>CG,MAX_STDOUT_BYTES:()=>WR});async function Jq($,X=WR){let Q=$.getReader(),z=new TextDecoder,Z="",K=0;try{while(K<X){let{done:J,value:q}=await Q.read();if(J)break;if(!q)continue;if(K+=q.byteLength,K>X){let V=q.byteLength-(K-X);Z+=z.decode(q.subarray(0,V),{stream:!0});break}Z+=z.decode(q,{stream:!0})}Z+=z.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return Z}async function $1($,X={}){let Q=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),z,Z;if(X.timeoutMs&&X.timeoutMs>0)z=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[K,J,q]=await Promise.all([Jq(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:K,stderr:J,exitCode:q}}finally{if(z)clearTimeout(z);if(Z)clearTimeout(Z)}}async function Ue($,X={}){let Q=await $1($,X);if(Q.exitCode!==0)throw new CG(`command failed (${Q.exitCode}): ${$.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function g5($){let X=He($),Q=await $1(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function He($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function We($,X="--version"){if(!await g5($))return null;let z=await $1([$,X],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var WR=16777216,CG;var y8=s(()=>{CG=class CG extends Error{message;exitCode;stdout;stderr;constructor($,X,Q,z){super($);this.message=$;this.exitCode=X;this.stdout=Q;this.stderr=z;this.name="ShellError"}}});function g2($){return Ge?"":$}var Ge,p0,$5,q1,L61,A1,f1,m5,r;var t7=s(()=>{Ge=(process.env.NO_COLOR??"").length>0;p0=g2("\x1B[0;31m"),$5=g2("\x1B[0;32m"),q1=g2("\x1B[1;33m"),L61=g2("\x1B[0;34m"),A1=g2("\x1B[0;36m"),f1=g2("\x1B[1m"),m5=g2("\x1B[2m"),r=g2("\x1B[0m")});import{existsSync as De}from"fs";async function Z2(){if(GQ!==void 0)return GQ;let $="/opt/homebrew/bin/python3.12";if(De($))return GQ=$,$;let X=await g5("python3.12");if(X)return GQ=X,X;let Q=await g5("python3");return GQ=Q,Q}async function I4($,X={}){let Q=await Z2();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return $1([Q,"-c",$],X)}var GQ;var m2=s(()=>{y8()});var yR={};B1(yR,{runStatus:()=>oe});import{existsSync as u5,readFileSync as A9,readdirSync as RR,statSync as IR}from"fs";import{resolve as A5,basename as ge}from"path";import{homedir as me}from"os";function wR($){let X=Math.trunc($);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 PR($,X,Q){if(X===0)return null;let z=Math.trunc($*100/X),Z=Math.trunc($*Vq/X);if(Z>Vq)Z=Vq;let K=Vq-Z,J=$5;if(z>=80)J=p0;else if(z>=50)J=q1;let q="=".repeat(Math.max(0,Z))+" ".repeat(Math.max(0,K)),V=wR($),Y=wR(X);return` ${f1}${Q}${r} ${J}[${q}]${r} ${z}% (${V} / ${Y})`}async function de(){if(await g5("jq"))return!0;return process.stdout.write(`${p0}Error: jq is required but not installed.${r}
|
|
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)
|
|
@@ -1334,4 +1334,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
1334
1334
|
`),2}case"start":{let{runStart:z}=await Promise.resolve().then(() => (Et(),Pt));return z(Q)}default:return process.stderr.write(`Unknown command: ${X}
|
|
1335
1335
|
`),process.stderr.write(xt),2}}DR();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var X61=await $61(Bun.argv.slice(2));process.exit(X61);
|
|
1336
1336
|
|
|
1337
|
-
//# debugId=
|
|
1337
|
+
//# debugId=AA95222A4C31D0041FE66BEAFA8F2DD1
|
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.
|
|
4
|
+
"version": "9.30.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, opencode).",
|
|
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.
|
|
5
|
+
"version": "9.30.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",
|
package/src/audit/crosslink.js
CHANGED
|
@@ -385,11 +385,17 @@ function verifyUnified(opts) {
|
|
|
385
385
|
opts.witnessFile ||
|
|
386
386
|
path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE));
|
|
387
387
|
|
|
388
|
+
// Reconcile witnessed tips against the live chain. verifyWitnessFile above
|
|
389
|
+
// only checks the witness file's own monotonicity, so without this a
|
|
390
|
+
// re-forged chain passed with a witness sitting right next to it recording
|
|
391
|
+
// the honest tip. Reproduced before the fix; see the helper's comment.
|
|
392
|
+
var witnessedPrefix = reconcileWitnessedPrefix(opts);
|
|
393
|
+
|
|
388
394
|
var dashboardOk = dash.available ? !!dash.valid : !requireDashboard;
|
|
389
395
|
var crosslinkOk = requireCrosslink ? anchors.length > 0 : true;
|
|
390
396
|
|
|
391
397
|
var valid = !!agentResult.valid && dashboardOk && anchorReconcile.valid &&
|
|
392
|
-
witness.valid && crosslinkOk;
|
|
398
|
+
witness.valid && witnessedPrefix.valid && crosslinkOk;
|
|
393
399
|
|
|
394
400
|
return {
|
|
395
401
|
valid: valid,
|
|
@@ -397,6 +403,7 @@ function verifyUnified(opts) {
|
|
|
397
403
|
dashboard: dash,
|
|
398
404
|
anchors: anchorReconcile,
|
|
399
405
|
witness: witness,
|
|
406
|
+
witnessedPrefix: witnessedPrefix,
|
|
400
407
|
requireDashboard: requireDashboard,
|
|
401
408
|
requireCrosslink: requireCrosslink,
|
|
402
409
|
};
|
|
@@ -523,6 +530,117 @@ function linkManifest(opts) {
|
|
|
523
530
|
* @returns {object} { present, highWater } -- highWater:0 and
|
|
524
531
|
* present:false when no witness file / no usable counts exist.
|
|
525
532
|
*/
|
|
533
|
+
/**
|
|
534
|
+
* Reconcile every witnessed agent tip against the CURRENT chain.
|
|
535
|
+
*
|
|
536
|
+
* Why this exists, measured: the agent chain hash is unkeyed over public
|
|
537
|
+
* fields from a constant genesis (log.js:16,127-134), so anyone who can write
|
|
538
|
+
* the log can recompute a fully consistent chain over invented history --
|
|
539
|
+
* verifyChain() returns valid:true for it. A witness pins what the tip really
|
|
540
|
+
* was at a point in time, which is the one thing the forger cannot retroactively
|
|
541
|
+
* change once it has left the machine. See docs/AUDIT-CHAIN-THREAT-MODEL.md.
|
|
542
|
+
*
|
|
543
|
+
* Before this, writeWitness recorded that tip and NOTHING ever compared it to
|
|
544
|
+
* the live chain: verifyWitnessFile only checks the witness file's own
|
|
545
|
+
* monotonicity. A witness nobody reconciles is not a control.
|
|
546
|
+
*
|
|
547
|
+
* The comparison must be PREFIX-based, not tip-equality: the chain legitimately
|
|
548
|
+
* grows after a witness is taken, so a differing live tip is normal. What is
|
|
549
|
+
* NOT normal is entry N of the current chain hashing differently than when a
|
|
550
|
+
* witness saw N entries. That is a rewrite of already-witnessed history.
|
|
551
|
+
*
|
|
552
|
+
* Honest states, never collapsed:
|
|
553
|
+
* checked - at least one witness was reconciled against the chain
|
|
554
|
+
* no_records- no witness file, or none carrying a usable tip. NOT a pass.
|
|
555
|
+
* unreadable- the chain or witness file could not be read. NOT a pass.
|
|
556
|
+
*
|
|
557
|
+
* @returns {{state:string, valid:boolean, witnessesChecked:number,
|
|
558
|
+
* rewrittenAt:(number|null), reason:(string|undefined)}}
|
|
559
|
+
*/
|
|
560
|
+
function reconcileWitnessedPrefix(opts) {
|
|
561
|
+
opts = opts || {};
|
|
562
|
+
var witnessFile = opts.witnessFile ||
|
|
563
|
+
path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE);
|
|
564
|
+
|
|
565
|
+
if (!fs.existsSync(witnessFile)) {
|
|
566
|
+
return { state: 'no_records', valid: true, witnessesChecked: 0,
|
|
567
|
+
rewrittenAt: null,
|
|
568
|
+
reason: 'no witness file; witnessed-prefix reconciliation did not run' };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
var records = [];
|
|
572
|
+
try {
|
|
573
|
+
var content = fs.readFileSync(witnessFile, 'utf8').trim();
|
|
574
|
+
if (!content) {
|
|
575
|
+
return { state: 'no_records', valid: true, witnessesChecked: 0,
|
|
576
|
+
rewrittenAt: null, reason: 'witness file is empty' };
|
|
577
|
+
}
|
|
578
|
+
var lines = content.split('\n');
|
|
579
|
+
for (var i = 0; i < lines.length; i++) {
|
|
580
|
+
var rec;
|
|
581
|
+
try { rec = JSON.parse(lines[i]); } catch (_) { continue; }
|
|
582
|
+
if (rec && typeof rec.agentEntries === 'number' &&
|
|
583
|
+
typeof rec.agentTipHash === 'string' && rec.agentTipHash) {
|
|
584
|
+
records.push(rec);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} catch (e) {
|
|
588
|
+
return { state: 'unreadable', valid: false, witnessesChecked: 0,
|
|
589
|
+
rewrittenAt: null,
|
|
590
|
+
reason: 'witness file unreadable: ' + String((e && e.message) || e) };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (records.length === 0) {
|
|
594
|
+
return { state: 'no_records', valid: true, witnessesChecked: 0,
|
|
595
|
+
rewrittenAt: null,
|
|
596
|
+
reason: 'witness file carries no usable agent tip' };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
var entries;
|
|
600
|
+
try {
|
|
601
|
+
var log = new AuditLog(opts);
|
|
602
|
+
entries = log.readEntries();
|
|
603
|
+
log.destroy();
|
|
604
|
+
} catch (e) {
|
|
605
|
+
return { state: 'unreadable', valid: false, witnessesChecked: 0,
|
|
606
|
+
rewrittenAt: null,
|
|
607
|
+
reason: 'agent chain unreadable: ' + String((e && e.message) || e) };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
var checked = 0;
|
|
611
|
+
for (var j = 0; j < records.length; j++) {
|
|
612
|
+
var r = records[j];
|
|
613
|
+
var n = r.agentEntries;
|
|
614
|
+
if (n <= 0) continue;
|
|
615
|
+
// Fewer entries than were witnessed is truncation of witnessed history.
|
|
616
|
+
if (entries.length < n) {
|
|
617
|
+
return { state: 'checked', valid: false, witnessesChecked: checked,
|
|
618
|
+
rewrittenAt: entries.length,
|
|
619
|
+
reason: 'audit chain has ' + entries.length + ' entries but a witness ' +
|
|
620
|
+
'recorded ' + n + '; witnessed history was truncated' };
|
|
621
|
+
}
|
|
622
|
+
// Entry n-1 is the tip AS WITNESSED. Its stored hash must still match.
|
|
623
|
+
var atWitness = entries[n - 1];
|
|
624
|
+
var liveHash = atWitness && atWitness.hash;
|
|
625
|
+
if (liveHash !== r.agentTipHash) {
|
|
626
|
+
return { state: 'checked', valid: false, witnessesChecked: checked,
|
|
627
|
+
rewrittenAt: n,
|
|
628
|
+
reason: 'entry ' + n + ' hashes ' + String(liveHash).slice(0, 16) +
|
|
629
|
+
'... but a witness recorded ' + String(r.agentTipHash).slice(0, 16) +
|
|
630
|
+
'...; witnessed history was rewritten' };
|
|
631
|
+
}
|
|
632
|
+
checked++;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (checked === 0) {
|
|
636
|
+
return { state: 'no_records', valid: true, witnessesChecked: 0,
|
|
637
|
+
rewrittenAt: null,
|
|
638
|
+
reason: 'no witness recorded a positive entry count' };
|
|
639
|
+
}
|
|
640
|
+
return { state: 'checked', valid: true, witnessesChecked: checked,
|
|
641
|
+
rewrittenAt: null };
|
|
642
|
+
}
|
|
643
|
+
|
|
526
644
|
function witnessAgentHighWater(opts) {
|
|
527
645
|
opts = opts || {};
|
|
528
646
|
var witnessFile = opts.witnessFile ||
|
|
@@ -674,6 +792,7 @@ module.exports = {
|
|
|
674
792
|
linkManifest: linkManifest,
|
|
675
793
|
verifyManifestLink: verifyManifestLink,
|
|
676
794
|
witnessAgentHighWater: witnessAgentHighWater,
|
|
795
|
+
reconcileWitnessedPrefix: reconcileWitnessedPrefix,
|
|
677
796
|
hashManifest: hashManifest,
|
|
678
797
|
defaultManifestPath: defaultManifestPath,
|
|
679
798
|
CROSSLINK_ACTION: CROSSLINK_ACTION,
|
package/src/audit/subscriber.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
var fs = require('fs');
|
|
5
5
|
var path = require('path');
|
|
6
6
|
var AuditLog = require('./log').AuditLog;
|
|
7
|
+
var crosslink = require('./crosslink');
|
|
7
8
|
|
|
8
9
|
var lokiDir = process.env.LOKI_DIR || '.loki';
|
|
9
10
|
var pendingDir = path.join(process.cwd(), lokiDir, 'events', 'pending');
|
|
@@ -58,6 +59,46 @@ function processEventFile(filepath) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Pin the current chain tip to the append-only witness file.
|
|
64
|
+
*
|
|
65
|
+
* Why the agent chain needs this: its hash is unkeyed over public fields from a
|
|
66
|
+
* constant genesis (log.js:16,127-134), so verifyChain() cannot tell an honest
|
|
67
|
+
* chain from one recomputed over invented history. A witness records what the
|
|
68
|
+
* tip actually was, and verifyUnified reconciles later chains against it
|
|
69
|
+
* (crosslink.reconcileWitnessedPrefix). Without a witness there is nothing to
|
|
70
|
+
* reconcile against, so this is what gives that check something to work with.
|
|
71
|
+
* See docs/AUDIT-CHAIN-THREAT-MODEL.md.
|
|
72
|
+
*
|
|
73
|
+
* Best-effort by design: a witness that cannot be written must never take down
|
|
74
|
+
* the run or block the audit flush. A missing witness is reported honestly by
|
|
75
|
+
* verifyUnified as state "no_records", never as a pass.
|
|
76
|
+
*
|
|
77
|
+
* LOKI_AUDIT_WITNESS=0 opts out.
|
|
78
|
+
* LOKI_AUDIT_WITNESS_COMMAND, if set, is invoked with the witness line so an
|
|
79
|
+
* external party (a WORM mount, a timestamping authority) holds an out-of-band
|
|
80
|
+
* copy. That out-of-band copy is the only form that survives an adversary who
|
|
81
|
+
* controls this machine: a local witness file is itself rewritable.
|
|
82
|
+
*/
|
|
83
|
+
function writeWitnessSafely(reason) {
|
|
84
|
+
if (process.env.LOKI_AUDIT_WITNESS === '0') return null;
|
|
85
|
+
try {
|
|
86
|
+
var opts = { projectDir: process.cwd() };
|
|
87
|
+
var cmd = process.env.LOKI_AUDIT_WITNESS_COMMAND;
|
|
88
|
+
if (cmd) opts.witnessCommand = cmd;
|
|
89
|
+
var res = crosslink.writeWitness(opts);
|
|
90
|
+
console.log('[audit-subscriber] witness written (' + reason + '), agentEntries=' +
|
|
91
|
+
(res && res.record && res.record.agentEntries));
|
|
92
|
+
return res;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// Reported, never swallowed: an operator who expected a witness must be
|
|
95
|
+
// able to see that none was taken.
|
|
96
|
+
console.error('[audit-subscriber] witness NOT written (' + reason + '): ' +
|
|
97
|
+
String((e && e.message) || e));
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
61
102
|
function scanPendingEvents() {
|
|
62
103
|
if (!fs.existsSync(pendingDir)) return;
|
|
63
104
|
try {
|
|
@@ -79,9 +120,28 @@ if (require.main === module) {
|
|
|
79
120
|
var pollInterval = setInterval(scanPendingEvents, 500);
|
|
80
121
|
scanPendingEvents();
|
|
81
122
|
|
|
123
|
+
// A witness taken only at shutdown is lost precisely when it matters most
|
|
124
|
+
// (SIGKILL, power loss, a crashed run). Pin one periodically so the
|
|
125
|
+
// witnessed prefix covers work already done. Default 300s; 0 disables.
|
|
126
|
+
var witnessEvery = parseInt(process.env.LOKI_AUDIT_WITNESS_INTERVAL_SEC || '300', 10);
|
|
127
|
+
var witnessInterval = null;
|
|
128
|
+
if (process.env.LOKI_AUDIT_WITNESS !== '0' &&
|
|
129
|
+
Number.isFinite(witnessEvery) && witnessEvery > 0) {
|
|
130
|
+
witnessInterval = setInterval(function () {
|
|
131
|
+
writeWitnessSafely('periodic');
|
|
132
|
+
}, witnessEvery * 1000);
|
|
133
|
+
// Do not hold the event loop open for the sake of witnessing.
|
|
134
|
+
if (witnessInterval.unref) witnessInterval.unref();
|
|
135
|
+
}
|
|
136
|
+
|
|
82
137
|
function shutdown() {
|
|
83
138
|
clearInterval(pollInterval);
|
|
139
|
+
if (witnessInterval) clearInterval(witnessInterval);
|
|
84
140
|
audit.flush();
|
|
141
|
+
// AFTER the flush: writeWitness reads the tip off disk, so witnessing
|
|
142
|
+
// before flushing would pin a tip that omits every buffered entry and
|
|
143
|
+
// then read as truncation on the next verify.
|
|
144
|
+
writeWitnessSafely('session end');
|
|
85
145
|
process.exit(0);
|
|
86
146
|
}
|
|
87
147
|
process.on('SIGTERM', shutdown);
|
|
@@ -93,6 +153,7 @@ if (require.main === module) {
|
|
|
93
153
|
scanPendingEvents: scanPendingEvents,
|
|
94
154
|
EVENT_TO_AUDIT: EVENT_TO_AUDIT,
|
|
95
155
|
_setAudit: function(a) { audit = a; },
|
|
156
|
+
writeWitnessSafely: writeWitnessSafely,
|
|
96
157
|
_setPendingDir: function(d) { pendingDir = d; },
|
|
97
158
|
_getLastProcessedFile: function() { return lastProcessedFile; },
|
|
98
159
|
_resetState: function() { lastProcessedFile = ''; },
|