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 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.28.1
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.28.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.30.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.28.1
1
+ 9.30.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.28.1"
10
+ __version__ = "9.30.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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, creating a tamper-evident
198
- chain. If any entry is modified, all subsequent hashes will be invalid.
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.
@@ -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.28.1
5
+ **Version:** v9.30.0
6
6
 
7
7
  ---
8
8
 
@@ -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.28.1";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}
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=933CC84685D51DDC6BA1C1C5D1DD614C
1337
+ //# debugId=AA95222A4C31D0041FE66BEAFA8F2DD1
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.28.1'
78
+ __version__ = '9.30.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.28.1",
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.28.1",
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",
@@ -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,
@@ -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 = ''; },