@compr/opscontext-mcp 2.4.2 → 2.4.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.4.3] — 2026-08-17 — The audit verifier called concurrency "tampering" and condemned 316k records
8
+
9
+ `audit-verify` reported `❌ Audit chain BROKEN at index 2826 (of 319438)` and told the user the
10
+ log "was either edited after the fact, or a record was partially written during a crash… treat
11
+ all records from the break onward as unverified." That declared the entire SOC 2 / ISO evidence
12
+ base worthless — for a condition it had never actually tested.
13
+
14
+ Measured against the real 319k-record log: **0 records with altered content, 0 orphans, 66
15
+ concurrent-append forks.** Nothing had ever been edited. The verifier was reporting a verdict
16
+ about something it had not assessed — `[ABSENCE-IS-NOT-A-VERDICT]`, in the compliance feature
17
+ itself.
18
+
19
+ ### Fixed
20
+
21
+ - **`verifyChain` now classifies instead of bailing on the first linkage mismatch**
22
+ (`[VERIFY-FORK-IS-NOT-TAMPER]`). Three distinct findings, previously collapsed into one:
23
+ | Condition | Meaning | Verdict |
24
+ |---|---|---|
25
+ | record's own hash ≠ its content | content was altered | **TAMPER** — fail |
26
+ | `prev_hash` names a hash absent from the log | history deleted/truncated | **ORPHAN** — fail |
27
+ | `prev_hash` names a *known earlier* head | two processes appended concurrently | **FORK** — warn, still valid |
28
+ Content is now hashed against each record's **own** `prev_hash`, so a single fork no longer
29
+ cascades into false tamper reports for every record after it. The report carries
30
+ `tamperedIndices`, `orphanIndices` and `forkIndices`, and the CLI states plainly that a forked
31
+ log must **not** be rewritten to linearise it — doing so destroys the evidence it exists to provide.
32
+ - **Head-of-chain read is now O(1)** (`[AUDIT-TAIL-READ-IS-O1]`). It read the whole log and split
33
+ it on every append — **215 ms on a 120 MB log, inside the append lock**, allocating a
34
+ 319k-element array each time. Now seeks the last 64 KB. Measured **0.3 ms/append, ~700× faster**;
35
+ the lock is held for microseconds instead of a fifth of a second.
36
+ - **The in-process head cache is gone** (`[AUDIT-HEAD-FROM-DISK]`). Its safety guard compared a
37
+ locally *incremented* byte count against the real file size, so any divergence silently hashed
38
+ onto a stale head. The file's own `[audit-001-write-race]` LOCK already warned the cache was
39
+ "a perf optimization, NOT a correctness guarantee". With the O(1) read there is nothing left to
40
+ optimise, so the head is read from disk under the lock, every time.
41
+
42
+ ### Honest scope
43
+
44
+ The 64 forks dated 2026-06-10 predate the append lock (added 2026-06-24, v2.1.1). Two more dated
45
+ 2026-08-13 are **not explained** — the locked writer could not be made to fork under 16 concurrent
46
+ processes on either a small or a 120 MB log, and `emit-event` does use the locked path. The cache
47
+ removal eliminates the most plausible remaining mechanism *by construction*, but that is reasoning,
48
+ not a reproduction. Recorded rather than papered over.
49
+
50
+ **No log rewrite. No re-anchoring.** The forks are honest history.
51
+
52
+ ### Tests
53
+
54
+ 381 pass (8 new): fork-vs-tamper-vs-orphan classification, fork non-cascade, orphan/fork
55
+ disambiguation, cross-process head pickup, and tail-window overflow. Two existing tests asserted
56
+ the old prose and were re-pointed at the new classification rather than deleted.
57
+
7
58
  ## [2.4.2] — 2026-08-16 — Close the three holes 2.4.1 left in "a directory is not a project"
8
59
 
9
60
  2.4.1 made `score`'s scope explicit but enforced it on only one of three entry points. An
package/dist/audit.d.ts CHANGED
@@ -14,7 +14,38 @@ export interface IntegrityReport {
14
14
  total: number;
15
15
  breakAtIndex: number | null;
16
16
  breakReason: string | null;
17
+ /** Records whose own hash does not match their content. Non-empty = TAMPERED. */
18
+ tamperedIndices?: number[];
19
+ /** Records whose prev_hash names a hash that appears nowhere earlier in the log.
20
+ * Indicates deletion/truncation of history — treated as tampering. */
21
+ orphanIndices?: number[];
22
+ /** Records whose prev_hash names a KNOWN earlier head — a concurrent-append fork.
23
+ * Content is provably intact; only the linkage is non-linear. Not tampering. */
24
+ forkIndices?: number[];
17
25
  }
26
+ /**
27
+ * 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
28
+ * ⛔ NEVER report a forked chain as "the log was edited", and never stop at the first
29
+ * linkage mismatch without first checking whether any record's CONTENT is altered.
30
+ * WHY: the previous verifier returned on the first `prev_hash !== prev` and told the user
31
+ * the log "was either edited after the fact, or a record was partially written during
32
+ * a crash… treat all records from the break onward as unverified." On the author's log
33
+ * that meant declaring 316,000 records unverifiable — destroying the entire SOC 2 /
34
+ * ISO evidence claim — for a condition it had never actually tested. The truth, once
35
+ * measured: 0 of 319,461 records had an invalid self-hash (nothing was ever edited),
36
+ * 0 orphans (nothing was deleted), and all 66 breaks were forks where two processes
37
+ * read the same head and both appended.
38
+ * Tampering and concurrency produce DIFFERENT evidence, and conflating them is
39
+ * [ABSENCE-IS-NOT-A-VERDICT] applied to the compliance feature itself: the verifier
40
+ * reported a verdict ("edited") for something it had not assessed.
41
+ * FIX: classify every anomaly instead of bailing on the first.
42
+ * - self-hash mismatch → TAMPER (fail hard; content was altered)
43
+ * - prev_hash unknown → ORPHAN (fail hard; history was deleted/truncated)
44
+ * - prev_hash = a known earlier head → FORK (warn; concurrent append, content intact)
45
+ * `ok` is true when there are no tampered and no orphan records. Forks are surfaced
46
+ * with counts and indices so the report stays honest in both directions — it must
47
+ * never claim a forked log is pristine either.
48
+ */
18
49
  export declare function verifyChain(): IntegrityReport;
19
50
  export declare function filterByRange(records: AuditRecord[], since?: string, until?: string): AuditRecord[];
20
51
  export declare function toCsv(records: AuditRecord[]): string;
package/dist/audit.js CHANGED
@@ -25,16 +25,18 @@
25
25
  // (activation server + main MCP) reading the same prev_hash before
26
26
  // either had flushed. The lock serializes the read-then-write
27
27
  // window across processes.
28
- // ⛔ NEVER trust cachedLastHash without verifying file size hasn't
29
- // grown since cachedSize. Another process may have written between
30
- // OUR last write and OUR next read.
28
+ // ⛔ NEVER reintroduce an in-process head cache. STRENGTHENED 2026-08-17:
29
+ // the cache is GONE, not merely guarded. See [AUDIT-HEAD-FROM-DISK].
31
30
  // WHY: audit-001-write-race documented in Session 11 SCORE.md. The
32
- // in-process chain cache is a perf optimization, NOT a correctness
33
- // guarantee — correctness comes from the lock + the size-mismatch
34
- // re-read.
35
- // FIX: To raise throughput further (if profiling proves the stat() per
36
- // append is hot), batch appends within a process behind a single
37
- // lock acquisition. Don't remove the lock.
31
+ // in-process chain cache was a perf optimization, NOT a correctness
32
+ // guarantee — and the size-mismatch guard that was supposed to make it
33
+ // safe compared a locally-INCREMENTED byte count against the real file
34
+ // size, so any divergence silently hashed onto a stale head.
35
+ // The optimization is also now pointless: [AUDIT-TAIL-READ-IS-O1] made
36
+ // reading the true head ~0ms, down from 215ms on a 120MB log.
37
+ // FIX: hold the lock, read the real head from disk, append. Nothing else.
38
+ // Don't remove the lock, and don't add a cache back to "speed up" a
39
+ // path that is already O(1).
38
40
  //
39
41
  // Tamper-evident audit log — hash-chained JSONL at ~/.contextengine/audit.log.
40
42
  //
@@ -44,7 +46,7 @@
44
46
  // Records every state-changing operation. Each line carries the SHA-256 hash
45
47
  // of the previous line's canonical content, so mutation of any historical
46
48
  // record breaks chain verification at that index.
47
- import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, constants, } from "fs";
49
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, constants, } from "fs";
48
50
  import { join } from "path";
49
51
  import { homedir } from "os";
50
52
  import { createHash } from "crypto";
@@ -132,17 +134,68 @@ function ensureDir() {
132
134
  mkdirSync(dir, { recursive: true });
133
135
  }
134
136
  }
137
+ /**
138
+ * 🔒 LOCKED [AUDIT-TAIL-READ-IS-O1] — 2026-08-17
139
+ * ⛔ NEVER go back to readFileSync(whole log) + split("\n") to find the head.
140
+ * WHY: this ran INSIDE the append lock, so its cost was lock hold time. On the author's
141
+ * 319k-record / 120 MB log it measured **215 ms per append**, and it grows without
142
+ * bound. `acquireLockSync` force-breaks any lock older than STALE_LOCK_MS (10 s) to
143
+ * recover from crashed holders — which means a slow-but-perfectly-alive holder can
144
+ * have its lock STOLEN under load. Both processes then compute a hash from the same
145
+ * head and append: a forked chain. 66 forks exist in the log, all with prev_hash
146
+ * pointing at a known earlier head, none with tampered content.
147
+ * The whole-file read also allocated a 319k-element string array per append, on a
148
+ * path invoked once per PostToolUse hook firing.
149
+ * FIX: seek the tail. Read at most TAIL_READ_BYTES from the end and take the last complete
150
+ * line. O(1) in log size, ~0 ms, so the lock is held for microseconds and stale-break
151
+ * cannot fire on a live holder. Falls back to a full read only if the tail window
152
+ * somehow contains no complete line (pathologically long single record).
153
+ */
154
+ const TAIL_READ_BYTES = 64 * 1024;
135
155
  function readLastHash() {
136
156
  const path = auditPath();
137
157
  if (!existsSync(path))
138
158
  return GENESIS_HASH;
159
+ const size = statSync(path).size;
160
+ if (size === 0)
161
+ return GENESIS_HASH;
162
+ const start = Math.max(0, size - TAIL_READ_BYTES);
163
+ let tail;
164
+ const fd = openSync(path, constants.O_RDONLY);
165
+ try {
166
+ const buf = Buffer.alloc(size - start);
167
+ readSync(fd, buf, 0, buf.length, start);
168
+ tail = buf.toString("utf-8");
169
+ }
170
+ finally {
171
+ closeSync(fd);
172
+ }
173
+ // Drop a leading partial line when we started mid-record.
174
+ if (start > 0) {
175
+ const nl = tail.indexOf("\n");
176
+ tail = nl === -1 ? "" : tail.slice(nl + 1);
177
+ }
178
+ const lines = tail.split("\n").filter(Boolean);
179
+ if (lines.length === 0) {
180
+ // Tail window held no complete record — fall back to the full read.
181
+ return readLastHashFullScan();
182
+ }
183
+ try {
184
+ return JSON.parse(lines[lines.length - 1]).hash;
185
+ }
186
+ catch {
187
+ return GENESIS_HASH;
188
+ }
189
+ }
190
+ /** Fallback for the pathological case: a single record longer than TAIL_READ_BYTES. */
191
+ function readLastHashFullScan() {
192
+ const path = auditPath();
139
193
  const data = readFileSync(path, "utf-8");
140
194
  const lines = data.split("\n").filter(Boolean);
141
195
  if (lines.length === 0)
142
196
  return GENESIS_HASH;
143
197
  try {
144
- const last = JSON.parse(lines[lines.length - 1]);
145
- return last.hash;
198
+ return JSON.parse(lines[lines.length - 1]).hash;
146
199
  }
147
200
  catch {
148
201
  return GENESIS_HASH;
@@ -163,28 +216,32 @@ export function appendAudit(event, payload, actor = "system") {
163
216
  const release = acquireLockSync();
164
217
  try {
165
218
  const path = auditPath();
166
- // Cache validity check: if file size grew since OUR last write, another
167
- // process appended re-read prev hash from disk (the cache is stale).
168
- // Also handles first-ever call (cachedLastHash === null).
169
- const currentSize = existsSync(path) ? statSync(path).size : 0;
170
- if (cachedLastHash === null || currentSize !== cachedSize) {
171
- cachedLastHash = readLastHash();
172
- cachedSize = currentSize;
173
- }
219
+ // 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] 2026-08-17
220
+ // NEVER derive the head hash from an in-process cache again.
221
+ // WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
222
+ // a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
223
+ // not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
224
+ // — a partial write, a concurrent writer whose bytes happened to sum the same, an
225
+ // externally rotated/truncated log — left us hashing onto a head that is not the
226
+ // real tail, forking the chain. It was a correctness guarantee resting on a
227
+ // perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
228
+ // against ("the in-process chain cache is a perf optimization, NOT a correctness
229
+ // guarantee").
230
+ // FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
231
+ // to optimise. Read it from disk under the lock, every time. The cache is gone.
232
+ const prevHash = readLastHash();
174
233
  const ts = new Date().toISOString();
175
- const hash = computeHash(cachedLastHash, ts, event, actor, payload);
234
+ const hash = computeHash(prevHash, ts, event, actor, payload);
176
235
  const record = {
177
236
  ts,
178
237
  event,
179
238
  actor,
180
239
  payload,
181
- prev_hash: cachedLastHash,
240
+ prev_hash: prevHash,
182
241
  hash,
183
242
  };
184
243
  const line = JSON.stringify(record) + "\n";
185
244
  appendFileSync(path, line);
186
- cachedLastHash = hash;
187
- cachedSize += Buffer.byteLength(line, "utf-8");
188
245
  return record;
189
246
  }
190
247
  finally {
@@ -208,6 +265,29 @@ export function readAuditLog() {
208
265
  }
209
266
  });
210
267
  }
268
+ /**
269
+ * 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
270
+ * ⛔ NEVER report a forked chain as "the log was edited", and never stop at the first
271
+ * linkage mismatch without first checking whether any record's CONTENT is altered.
272
+ * WHY: the previous verifier returned on the first `prev_hash !== prev` and told the user
273
+ * the log "was either edited after the fact, or a record was partially written during
274
+ * a crash… treat all records from the break onward as unverified." On the author's log
275
+ * that meant declaring 316,000 records unverifiable — destroying the entire SOC 2 /
276
+ * ISO evidence claim — for a condition it had never actually tested. The truth, once
277
+ * measured: 0 of 319,461 records had an invalid self-hash (nothing was ever edited),
278
+ * 0 orphans (nothing was deleted), and all 66 breaks were forks where two processes
279
+ * read the same head and both appended.
280
+ * Tampering and concurrency produce DIFFERENT evidence, and conflating them is
281
+ * [ABSENCE-IS-NOT-A-VERDICT] applied to the compliance feature itself: the verifier
282
+ * reported a verdict ("edited") for something it had not assessed.
283
+ * FIX: classify every anomaly instead of bailing on the first.
284
+ * - self-hash mismatch → TAMPER (fail hard; content was altered)
285
+ * - prev_hash unknown → ORPHAN (fail hard; history was deleted/truncated)
286
+ * - prev_hash = a known earlier head → FORK (warn; concurrent append, content intact)
287
+ * `ok` is true when there are no tampered and no orphan records. Forks are surfaced
288
+ * with counts and indices so the report stays honest in both directions — it must
289
+ * never claim a forked log is pristine either.
290
+ */
211
291
  export function verifyChain() {
212
292
  let records;
213
293
  try {
@@ -221,29 +301,49 @@ export function verifyChain() {
221
301
  breakReason: e instanceof Error ? e.message : String(e),
222
302
  };
223
303
  }
304
+ const tampered = [];
305
+ const orphans = [];
306
+ const forks = [];
307
+ // Every hash observed so far, so a fork (parent = a known earlier head) can be told
308
+ // apart from an orphan (parent never existed in this log).
309
+ const seen = new Set([GENESIS_HASH]);
224
310
  let prev = GENESIS_HASH;
225
311
  for (let i = 0; i < records.length; i++) {
226
312
  const r = records[i];
313
+ // 1. Content integrity — the only check that can prove tampering. Computed against
314
+ // the record's OWN prev_hash, so a fork does not cascade into false tamper reports
315
+ // for every record after it.
316
+ const expected = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
317
+ if (r.hash !== expected)
318
+ tampered.push(i);
319
+ // 2. Linkage — fork vs orphan.
227
320
  if (r.prev_hash !== prev) {
228
- return {
229
- ok: false,
230
- total: records.length,
231
- breakAtIndex: i,
232
- breakReason: `prev_hash mismatch at index ${i}`,
233
- };
234
- }
235
- const expected = computeHash(prev, r.ts, r.event, r.actor, r.payload);
236
- if (r.hash !== expected) {
237
- return {
238
- ok: false,
239
- total: records.length,
240
- breakAtIndex: i,
241
- breakReason: `hash mismatch at index ${i} (record tampered)`,
242
- };
321
+ if (seen.has(r.prev_hash))
322
+ forks.push(i);
323
+ else
324
+ orphans.push(i);
243
325
  }
326
+ seen.add(r.hash);
244
327
  prev = r.hash;
245
328
  }
246
- return { ok: true, total: records.length, breakAtIndex: null, breakReason: null };
329
+ const ok = tampered.length === 0 && orphans.length === 0;
330
+ const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
331
+ let reason = null;
332
+ if (tampered.length > 0) {
333
+ reason = `${tampered.length} record(s) with altered content — first at index ${tampered[0]}`;
334
+ }
335
+ else if (orphans.length > 0) {
336
+ reason = `${orphans.length} record(s) whose parent is absent from the log (deleted or truncated history) — first at index ${orphans[0]}`;
337
+ }
338
+ return {
339
+ ok,
340
+ total: records.length,
341
+ breakAtIndex: firstProblem,
342
+ breakReason: reason,
343
+ tamperedIndices: tampered,
344
+ orphanIndices: orphans,
345
+ forkIndices: forks,
346
+ };
247
347
  }
248
348
  export function filterByRange(records, since, until) {
249
349
  return records.filter((r) => {
package/dist/cli.js CHANGED
@@ -1742,15 +1742,39 @@ reviewed, and validated in PR ahead of the hook wiring.`);
1742
1742
  }
1743
1743
  async function cliAuditVerify() {
1744
1744
  const report = verifyChain();
1745
+ const forks = report.forkIndices ?? [];
1745
1746
  if (report.ok) {
1746
- console.log(`✅ Audit chain verified — ${report.total} record(s), hash chain intact.`);
1747
+ console.log(`✅ Audit chain verified — ${report.total} record(s).`);
1748
+ console.log(` No record was altered, and no history is missing.`);
1749
+ if (forks.length > 0) {
1750
+ // [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
1751
+ console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
1752
+ console.log(` Two processes read the same chain head and both appended, so the log is`);
1753
+ console.log(` branched rather than strictly linear at: ${forks.slice(0, 8).join(", ")}${forks.length > 8 ? `, … (+${forks.length - 8} more)` : ""}`);
1754
+ console.log(` Every record's content still hashes correctly, so the tamper-evidence`);
1755
+ console.log(` property holds. Do NOT rewrite the log to linearise it — that would destroy`);
1756
+ console.log(` the evidence it exists to provide.`);
1757
+ }
1747
1758
  return;
1748
1759
  }
1749
- console.error(`❌ Audit chain BROKEN at index ${report.breakAtIndex} (of ${report.total}).`);
1750
- console.error(` Reason: ${report.breakReason}`);
1751
- console.error(`\nA broken chain means the log was either edited after the fact, or a record was`);
1752
- console.error(`partially written during a crash. For compliance-graded evidence, treat all`);
1753
- console.error(`records from the break onward as unverified.`);
1760
+ console.error(`❌ Audit chain FAILED ${report.total} record(s) checked.`);
1761
+ console.error(` ${report.breakReason}`);
1762
+ if ((report.tamperedIndices ?? []).length > 0) {
1763
+ const t = report.tamperedIndices;
1764
+ console.error(`\n Altered records (content does not match its own hash):`);
1765
+ console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
1766
+ console.error(` This is tampering: the record's bytes were changed after it was written.`);
1767
+ }
1768
+ if ((report.orphanIndices ?? []).length > 0) {
1769
+ const o = report.orphanIndices;
1770
+ console.error(`\n Orphaned records (parent hash absent from the log):`);
1771
+ console.error(` ${o.slice(0, 10).join(", ")}${o.length > 10 ? `, … (+${o.length - 10} more)` : ""}`);
1772
+ console.error(` This means earlier records were deleted or the log was truncated.`);
1773
+ }
1774
+ if (forks.length > 0) {
1775
+ console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
1776
+ }
1777
+ console.error(`\nFor compliance-graded evidence, treat the affected records as unverified.`);
1754
1778
  process.exit(2);
1755
1779
  }
1756
1780
  async function cliEndSession() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.4.2",
3
+ "version": "2.4.3",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",