loki-mode 9.29.0 → 9.30.1

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/README.md CHANGED
@@ -176,7 +176,7 @@ check it matches -- you are not asked to trust the agent's self-report.
176
176
 
177
177
  ---
178
178
 
179
- > **How it works:** Drop a spec -- a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief. Loki Mode classifies complexity (`run.sh:detect_complexity()`), assembles an agent team from 41 specialized agent roles across 8 domains - prompt-defined specifications the orchestrator adopts per phase, with parallel review (blind council) and optional worktree streams on Claude Code, sequential on other providers - and runs autonomous RARV cycles (Reason - Act - Reflect - Verify, see `run.sh:run_autonomous()`) with 8 quality gates (see `skills/quality-gates.md`). Code is not "done" until it passes automated verification. Output is a Git repo with source, tests, configs, and audit logs.
179
+ > **How it works:** Drop a spec -- a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief. Loki Mode classifies complexity (`run.sh:detect_complexity()`), selects reviewers from a specialist pool (`agents/types.json` ships 41 role definitions; 10 are keyword-scored by the review selector at `run.sh:FOCUS_KEYWORDS`, alongside the mandatory reviewers, and the rest are role descriptions in `references/agents.md` that the orchestrator adopts per phase rather than separate processes), with parallel review (blind council) and optional worktree streams on Claude Code, sequential on other providers - and runs autonomous RARV cycles (Reason - Act - Reflect - Verify, see `run.sh:run_autonomous()`) with 8 quality gates (see `skills/quality-gates.md`). Code is not "done" until it passes automated verification. Output is a Git repo with source, tests, configs, and audit logs.
180
180
 
181
181
  ---
182
182
 
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.29.0
6
+ # Loki Mode v9.30.1
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.29.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.30.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.29.0
1
+ 9.30.1
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.29.0"
10
+ __version__ = "9.30.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -80,6 +80,49 @@ increasing order of strength:
80
80
  Only 2 and 3 survive an adversary who controls the machine, which is the case
81
81
  that matters for a receipt a third party is meant to trust.
82
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
+
83
126
  ## Current honest claim
84
127
 
85
128
  Until a witness or off-machine signature is wired, the supportable claim is:
@@ -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.29.0
5
+ **Version:** v9.30.1
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.29.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}
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.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}
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=AC850A55189112371E3E6B205BFE8EF6
1337
+ //# debugId=1EB5CB676655413C8A4F19102A2C918F
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.29.0'
78
+ __version__ = '9.30.1'
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.29.0",
4
+ "version": "9.30.1",
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.29.0",
5
+ "version": "9.30.1",
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 = ''; },