loki-mode 9.29.0 → 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/docs/AUDIT-CHAIN-THREAT-MODEL.md +43 -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
|
@@ -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:
|
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 = ''; },
|