loki-mode 7.49.0 → 7.51.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.
@@ -0,0 +1,413 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Loki Mode Audit Cross-Link (P3-9 unification).
5
+ *
6
+ * The system has two independent tamper-evident audit chains:
7
+ *
8
+ * 1. Agent chain -- src/audit/log.js (Node)
9
+ * file: <project>/.loki/audit/audit.jsonl
10
+ * format: per-entry { ..., previousHash, hash }, genesis "GENESIS",
11
+ * hash = sha256(JSON of the linkable fields).
12
+ *
13
+ * 2. Dashboard chain -- dashboard/audit.py (Python)
14
+ * files: ~/.loki/dashboard/audit/audit-YYYY-MM-DD.jsonl (+ rotations)
15
+ * format: per-entry { ..., _integrity_hash }, genesis "0"*64,
16
+ * hash = sha256(prev_hash + entry_json).
17
+ *
18
+ * They use different directories, file layouts, genesis values and hash
19
+ * recipes, so a single physical chain is a large, risky merge. This
20
+ * module instead implements a *verifiable cross-link*: it folds the
21
+ * dashboard chain's current tip into the agent chain as an ordinary
22
+ * `audit_crosslink` record (so the anchor itself is protected by the
23
+ * agent chain's hash linkage), and ships a single `verifyUnified()`
24
+ * command that validates BOTH sub-chains AND reconciles every anchor
25
+ * against the live dashboard chain -- treating the pair as one logical,
26
+ * tamper-evident trail.
27
+ *
28
+ * It also provides an append-only / external-witness OPTION
29
+ * (`writeWitness`) so an external party can timestamp the unified root.
30
+ *
31
+ * Neither existing writer is modified or replaced: the agent writer
32
+ * (AuditLog.record) and the dashboard writer (audit.log_event) keep
33
+ * appending exactly as before. Full single-physical-chain unification
34
+ * (shared hash recipe + shared storage) is documented as follow-up.
35
+ */
36
+
37
+ var fs = require('fs');
38
+ var path = require('path');
39
+ var os = require('os');
40
+ var crypto = require('crypto');
41
+ var { execFileSync } = require('child_process');
42
+ var { AuditLog } = require('./log');
43
+
44
+ var CROSSLINK_ACTION = 'audit_crosslink';
45
+ var WITNESS_FILE = 'witness.jsonl';
46
+ var PY_GENESIS = '0'.repeat(64);
47
+
48
+ /**
49
+ * Resolve the default dashboard (Python) audit directory.
50
+ * Mirrors `AUDIT_DIR` in dashboard/audit.py: ~/.loki/dashboard/audit.
51
+ */
52
+ function defaultDashboardAuditDir() {
53
+ return path.join(os.homedir(), '.loki', 'dashboard', 'audit');
54
+ }
55
+
56
+ /**
57
+ * Resolve the path to dashboard/audit.py. Allows override via opts for
58
+ * tests and non-standard layouts; otherwise walks up from this file.
59
+ */
60
+ function resolveAuditPy(opts) {
61
+ if (opts && opts.auditPyPath) return opts.auditPyPath;
62
+ // src/audit/crosslink.js -> repo root is two levels up from src/.
63
+ var candidate = path.join(__dirname, '..', '..', 'dashboard', 'audit.py');
64
+ return candidate;
65
+ }
66
+
67
+ /**
68
+ * Resolve the python executable. Override via opts.pythonBin or env.
69
+ */
70
+ function resolvePython(opts) {
71
+ if (opts && opts.pythonBin) return opts.pythonBin;
72
+ return process.env.LOKI_PYTHON || 'python3';
73
+ }
74
+
75
+ /**
76
+ * Query the Python dashboard chain for its tip + verdict, by invoking
77
+ * the audit.py CLI shim. Returns a structured object; on any failure
78
+ * returns an `available:false` descriptor so the unified verifier can
79
+ * still report on the agent chain alone (honest partial result).
80
+ *
81
+ * @param {object} [opts]
82
+ * @param {string} [opts.dashboardAuditDir]
83
+ * @param {string} [opts.auditPyPath]
84
+ * @param {string} [opts.pythonBin]
85
+ */
86
+ function dashboardChainTip(opts) {
87
+ opts = opts || {};
88
+ var dir = opts.dashboardAuditDir || defaultDashboardAuditDir();
89
+ var py = resolvePython(opts);
90
+ var script = resolveAuditPy(opts);
91
+ if (!fs.existsSync(script)) {
92
+ return { available: false, reason: 'audit.py not found at ' + script,
93
+ tip_hash: PY_GENESIS, valid: false, entries: 0 };
94
+ }
95
+ try {
96
+ var out = execFileSync(py, [script, 'tip', dir], {
97
+ encoding: 'utf8',
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ var parsed = JSON.parse(out.trim());
101
+ parsed.available = true;
102
+ return parsed;
103
+ } catch (e) {
104
+ // execFileSync throws on non-zero exit. The shim exits 1 when the
105
+ // chain is INVALID but still prints valid JSON on stdout -- recover it.
106
+ if (e && e.stdout) {
107
+ try {
108
+ var recovered = JSON.parse(String(e.stdout).trim());
109
+ recovered.available = true;
110
+ return recovered;
111
+ } catch (_) { /* fall through */ }
112
+ }
113
+ return { available: false, reason: String((e && e.message) || e),
114
+ tip_hash: PY_GENESIS, valid: false, entries: 0 };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Recompute the dashboard chain hash after exactly the first `nEntries`
120
+ * integrity-bearing entries (the prefix pinned by a cross-link anchor),
121
+ * by invoking the audit.py `prefix` shim. Lets the unified verifier tell
122
+ * legitimate append-only GROWTH (prefix still reproduces the anchored
123
+ * tip) from TAMPER (prefix no longer reproduces it).
124
+ *
125
+ * Returns { available, found, prefix_hash, entries_available }.
126
+ */
127
+ function dashboardPrefixHash(nEntries, opts) {
128
+ opts = opts || {};
129
+ var dir = opts.dashboardAuditDir || defaultDashboardAuditDir();
130
+ var py = resolvePython(opts);
131
+ var script = resolveAuditPy(opts);
132
+ if (!fs.existsSync(script)) {
133
+ return { available: false, found: false, prefix_hash: PY_GENESIS,
134
+ entries_available: 0 };
135
+ }
136
+ function parse(out) {
137
+ var p = JSON.parse(String(out).trim());
138
+ p.available = true;
139
+ return p;
140
+ }
141
+ try {
142
+ return parse(execFileSync(py, [script, 'prefix', dir, String(nEntries)], {
143
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
144
+ }));
145
+ } catch (e) {
146
+ // Shim exits 1 (found:false) but still prints JSON on stdout.
147
+ if (e && e.stdout) {
148
+ try { return parse(e.stdout); } catch (_) { /* fall through */ }
149
+ }
150
+ return { available: false, found: false, prefix_hash: PY_GENESIS,
151
+ entries_available: 0 };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Read the agent (JS) chain tip hash without recording anything.
157
+ */
158
+ function agentChainTip(opts) {
159
+ var log = new AuditLog(opts || {});
160
+ // _loadChainTip ran in the constructor; expose the loaded tip + count.
161
+ var tip = log._lastHash;
162
+ var count = log._entryCount;
163
+ return { tip_hash: tip, entries: count, chain_id: 'loki-agent-audit',
164
+ genesis: 'GENESIS' };
165
+ }
166
+
167
+ /**
168
+ * Compute the unified root: a deterministic hash binding both chain tips
169
+ * together. This is the value an external witness timestamps.
170
+ */
171
+ function unifiedRoot(agentTip, dashboardTip) {
172
+ return crypto.createHash('sha256')
173
+ .update('loki-unified-audit-v1\n' + agentTip + '\n' + dashboardTip)
174
+ .digest('hex');
175
+ }
176
+
177
+ /**
178
+ * Create a cross-link: fold the dashboard chain tip into the agent chain
179
+ * as an `audit_crosslink` record. The anchor is therefore protected by
180
+ * the agent chain's existing hash linkage (tampering with the anchor
181
+ * breaks agent-chain verification), and it pins the dashboard chain
182
+ * state at this point in time (tampering with already-anchored dashboard
183
+ * history is caught by anchor reconciliation in verifyUnified).
184
+ *
185
+ * @param {object} [opts]
186
+ * @param {string} [opts.projectDir] project dir for the agent log
187
+ * @param {string} [opts.logDir] explicit agent log dir (tests)
188
+ * @param {string} [opts.dashboardAuditDir]
189
+ * @param {string} [opts.who] actor recorded on the anchor
190
+ * @returns {object} the recorded anchor entry plus dashboard verdict.
191
+ */
192
+ function crossLink(opts) {
193
+ opts = opts || {};
194
+ var dash = dashboardChainTip(opts);
195
+ var log = new AuditLog(opts);
196
+ var agentTip = log._lastHash;
197
+ var root = unifiedRoot(agentTip, dash.tip_hash || PY_GENESIS);
198
+ var anchor = log.record({
199
+ who: opts.who || 'audit-crosslink',
200
+ what: CROSSLINK_ACTION,
201
+ where: opts.dashboardAuditDir || defaultDashboardAuditDir(),
202
+ why: 'cross-link dashboard audit chain into agent audit chain',
203
+ metadata: {
204
+ dashboardChainId: dash.chain_id || 'loki-dashboard-audit',
205
+ dashboardTipHash: dash.tip_hash || PY_GENESIS,
206
+ dashboardEntries: dash.entries || 0,
207
+ dashboardValidAtLink: dash.available ? !!dash.valid : null,
208
+ dashboardAvailable: !!dash.available,
209
+ agentTipBeforeLink: agentTip,
210
+ unifiedRoot: root,
211
+ },
212
+ });
213
+ log.flush();
214
+ log.destroy();
215
+ return { anchor: anchor, dashboard: dash, unifiedRoot: root };
216
+ }
217
+
218
+ /**
219
+ * Append-only / external-witness OPTION.
220
+ *
221
+ * Writes the current unified root to an append-only witness file (one
222
+ * JSON line per witness, never rewritten). Optionally pipes the line to
223
+ * an external witness command (opts.witnessCommand, e.g. a timestamping
224
+ * authority or `tee` to a WORM mount) so an independent party holds an
225
+ * out-of-band copy. Returns the witness record.
226
+ *
227
+ * @param {object} [opts]
228
+ * @param {string} [opts.witnessFile] path to the append-only file
229
+ * @param {string} [opts.witnessCommand] external command (argv[0])
230
+ * @param {string[]} [opts.witnessArgs] extra args for the command
231
+ */
232
+ function writeWitness(opts) {
233
+ opts = opts || {};
234
+ var agent = agentChainTip(opts);
235
+ var dash = dashboardChainTip(opts);
236
+ var root = unifiedRoot(agent.tip_hash, dash.tip_hash || PY_GENESIS);
237
+ var record = {
238
+ type: 'loki-unified-audit-witness',
239
+ timestamp: new Date().toISOString(),
240
+ agentTipHash: agent.tip_hash,
241
+ agentEntries: agent.entries,
242
+ dashboardTipHash: dash.tip_hash || PY_GENESIS,
243
+ dashboardEntries: dash.entries || 0,
244
+ unifiedRoot: root,
245
+ };
246
+ var line = JSON.stringify(record);
247
+ var witnessFile = opts.witnessFile ||
248
+ path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE);
249
+ var witnessDir = path.dirname(witnessFile);
250
+ if (!fs.existsSync(witnessDir)) fs.mkdirSync(witnessDir, { recursive: true });
251
+ // Append-only: O_APPEND, never truncate or rewrite existing lines.
252
+ fs.appendFileSync(witnessFile, line + '\n', { encoding: 'utf8', flag: 'a' });
253
+
254
+ if (opts.witnessCommand) {
255
+ try {
256
+ execFileSync(opts.witnessCommand, (opts.witnessArgs || []).concat([line]), {
257
+ stdio: ['ignore', 'ignore', 'ignore'],
258
+ });
259
+ record.externalWitness = true;
260
+ } catch (e) {
261
+ record.externalWitness = false;
262
+ record.externalWitnessError = String((e && e.message) || e);
263
+ }
264
+ }
265
+ return { record: record, witnessFile: witnessFile };
266
+ }
267
+
268
+ /**
269
+ * Verify the witness file's own append-only continuity: each line must
270
+ * parse, and (if present) line N's agent/dashboard entry counts must be
271
+ * monotonic non-decreasing relative to line N-1. A shrinking count means
272
+ * the file was rewritten / truncated.
273
+ */
274
+ function verifyWitnessFile(witnessFile) {
275
+ if (!witnessFile || !fs.existsSync(witnessFile)) {
276
+ return { present: false, valid: true, witnesses: 0, brokenAt: null };
277
+ }
278
+ var content = fs.readFileSync(witnessFile, 'utf8').trim();
279
+ if (!content) return { present: true, valid: true, witnesses: 0, brokenAt: null };
280
+ var lines = content.split('\n');
281
+ var prevAgent = -1;
282
+ var prevDash = -1;
283
+ for (var i = 0; i < lines.length; i++) {
284
+ var rec;
285
+ try { rec = JSON.parse(lines[i]); } catch (e) {
286
+ return { present: true, valid: false, witnesses: i, brokenAt: i,
287
+ error: 'invalid JSON at witness line ' + i };
288
+ }
289
+ var a = typeof rec.agentEntries === 'number' ? rec.agentEntries : 0;
290
+ var d = typeof rec.dashboardEntries === 'number' ? rec.dashboardEntries : 0;
291
+ if (a < prevAgent || d < prevDash) {
292
+ return { present: true, valid: false, witnesses: i, brokenAt: i,
293
+ error: 'witness counts went backwards at line ' + i +
294
+ ' (append-only violated)' };
295
+ }
296
+ prevAgent = a;
297
+ prevDash = d;
298
+ }
299
+ return { present: true, valid: true, witnesses: lines.length, brokenAt: null };
300
+ }
301
+
302
+ /**
303
+ * Unified verification of the whole logical trail.
304
+ *
305
+ * Steps:
306
+ * 1. Verify the agent (JS) chain via AuditLog.verifyChain().
307
+ * 2. Verify the dashboard (Python) chain via audit.py.
308
+ * 3. For each `audit_crosslink` anchor in the agent chain, reconcile:
309
+ * - the anchor's unifiedRoot must equal
310
+ * sha256(agentTipBeforeLink, dashboardTipHash);
311
+ * - the MOST RECENT anchor's dashboardTipHash must equal the live
312
+ * dashboard tip (catches post-link tampering / truncation of
313
+ * dashboard history). Older anchors pin historical tips and are
314
+ * allowed to differ from the live tip (the chain grew).
315
+ * 4. (Optional) verify witness-file append-only continuity.
316
+ *
317
+ * The trail is `valid` only if every component that is present is valid.
318
+ * If the dashboard side is unavailable (e.g. Python missing), it is
319
+ * reported honestly as `available:false` and does not falsely pass.
320
+ *
321
+ * @param {object} [opts] same resolution opts as crossLink + optional
322
+ * opts.witnessFile and opts.requireDashboard (default true) and
323
+ * opts.requireCrosslink (default false).
324
+ */
325
+ function verifyUnified(opts) {
326
+ opts = opts || {};
327
+ var requireDashboard = opts.requireDashboard !== false;
328
+ var requireCrosslink = opts.requireCrosslink === true;
329
+
330
+ var log = new AuditLog(opts);
331
+ var agentResult = log.verifyChain();
332
+ var entries = log.readEntries();
333
+ log.destroy();
334
+
335
+ var dash = dashboardChainTip(opts);
336
+
337
+ // Reconcile cross-link anchors.
338
+ //
339
+ // For each anchor we check two things:
340
+ // 1. The anchor's own unifiedRoot is internally consistent (it was
341
+ // not edited in place: unifiedRoot == H(agentTip, dashboardTip)).
342
+ // This is also protected by the agent chain hash, but checking it
343
+ // here gives a precise reconciliation error.
344
+ // 2. The dashboard PREFIX the anchor pinned still reproduces. The
345
+ // dashboard chain is a live, continuously-appended log, so its
346
+ // live tip legitimately moves forward after a cross-link. Instead
347
+ // of comparing to the live tip (which would false-fail on every
348
+ // normal append), we recompute the hash of the first
349
+ // `dashboardEntries` entries and require it to equal the anchored
350
+ // `dashboardTipHash`. Append-only growth keeps that prefix intact;
351
+ // mutation at-or-before the anchor, or truncation below it, breaks
352
+ // reproducibility and is caught here.
353
+ var anchors = entries.filter(function (e) { return e.what === CROSSLINK_ACTION; });
354
+ var anchorReconcile = { count: anchors.length, valid: true, error: null };
355
+ for (var i = 0; i < anchors.length; i++) {
356
+ var m = anchors[i].metadata || {};
357
+ var expectRoot = unifiedRoot(
358
+ m.agentTipBeforeLink || '', m.dashboardTipHash || PY_GENESIS);
359
+ if (m.unifiedRoot !== expectRoot) {
360
+ anchorReconcile.valid = false;
361
+ anchorReconcile.error = 'anchor unifiedRoot mismatch at seq ' + anchors[i].seq;
362
+ break;
363
+ }
364
+ // Only reconcile the dashboard prefix when the dashboard side was
365
+ // available at link time AND is available now. An anchor that
366
+ // recorded an unavailable dashboard (dashboardAvailable=false) has
367
+ // nothing to reconcile against.
368
+ if (dash.available && m.dashboardAvailable) {
369
+ var pinnedTip = m.dashboardTipHash || PY_GENESIS;
370
+ var pinnedCount = typeof m.dashboardEntries === 'number' ? m.dashboardEntries : 0;
371
+ var prefix = dashboardPrefixHash(pinnedCount, opts);
372
+ if (!prefix.available || !prefix.found || prefix.prefix_hash !== pinnedTip) {
373
+ anchorReconcile.valid = false;
374
+ anchorReconcile.error =
375
+ 'dashboard prefix pinned by anchor seq ' + anchors[i].seq +
376
+ ' no longer reproduces (history tampered or truncated below the link point)';
377
+ break;
378
+ }
379
+ }
380
+ }
381
+
382
+ var witness = verifyWitnessFile(
383
+ opts.witnessFile ||
384
+ path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE));
385
+
386
+ var dashboardOk = dash.available ? !!dash.valid : !requireDashboard;
387
+ var crosslinkOk = requireCrosslink ? anchors.length > 0 : true;
388
+
389
+ var valid = !!agentResult.valid && dashboardOk && anchorReconcile.valid &&
390
+ witness.valid && crosslinkOk;
391
+
392
+ return {
393
+ valid: valid,
394
+ agent: agentResult,
395
+ dashboard: dash,
396
+ anchors: anchorReconcile,
397
+ witness: witness,
398
+ requireDashboard: requireDashboard,
399
+ requireCrosslink: requireCrosslink,
400
+ };
401
+ }
402
+
403
+ module.exports = {
404
+ crossLink: crossLink,
405
+ verifyUnified: verifyUnified,
406
+ writeWitness: writeWitness,
407
+ verifyWitnessFile: verifyWitnessFile,
408
+ dashboardChainTip: dashboardChainTip,
409
+ agentChainTip: agentChainTip,
410
+ unifiedRoot: unifiedRoot,
411
+ defaultDashboardAuditDir: defaultDashboardAuditDir,
412
+ CROSSLINK_ACTION: CROSSLINK_ACTION,
413
+ };
@@ -18,6 +18,7 @@
18
18
  var { AuditLog } = require('./log');
19
19
  var compliance = require('./compliance');
20
20
  var { ResidencyController } = require('./residency');
21
+ var crosslink = require('./crosslink');
21
22
 
22
23
  var _log = null;
23
24
  var _residency = null;
@@ -121,6 +122,34 @@ function flush() {
121
122
  if (_log) _log.flush();
122
123
  }
123
124
 
125
+ /**
126
+ * Cross-link the dashboard (Python) audit chain into the agent (JS)
127
+ * audit chain, producing a single verifiable tamper-evident trail.
128
+ * See src/audit/crosslink.js.
129
+ */
130
+ function crossLink(opts) {
131
+ if (!_initialized) init();
132
+ return crosslink.crossLink(Object.assign({ projectDir: _projectDir }, opts || {}));
133
+ }
134
+
135
+ /**
136
+ * Verify the unified (agent + dashboard) audit trail as one logical
137
+ * chain: both sub-chains valid AND every cross-link anchor reconciled.
138
+ */
139
+ function verifyUnified(opts) {
140
+ if (!_initialized) init();
141
+ return crosslink.verifyUnified(Object.assign({ projectDir: _projectDir }, opts || {}));
142
+ }
143
+
144
+ /**
145
+ * Append-only / external-witness option: write the current unified root
146
+ * to an append-only witness file (and optionally an external command).
147
+ */
148
+ function writeWitness(opts) {
149
+ if (!_initialized) init();
150
+ return crosslink.writeWitness(Object.assign({ projectDir: _projectDir }, opts || {}));
151
+ }
152
+
124
153
  /**
125
154
  * Destroy audit trail (for testing).
126
155
  */
@@ -144,4 +173,7 @@ module.exports = {
144
173
  getSummary: getSummary,
145
174
  flush: flush,
146
175
  destroy: destroy,
176
+ crossLink: crossLink,
177
+ verifyUnified: verifyUnified,
178
+ writeWitness: writeWitness,
147
179
  };