@compr/opscontext-mcp 2.5.1 ā 2.5.2
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/dist/audit.d.ts +59 -2
- package/dist/audit.js +231 -14
- package/dist/cli-commands.d.ts +30 -0
- package/dist/cli-commands.js +102 -0
- package/dist/cli.js +100 -3
- package/dist/detector.js +4 -1
- package/package.json +1 -1
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
|
@@ -8,7 +8,64 @@ export interface AuditRecord {
|
|
|
8
8
|
hash: string;
|
|
9
9
|
}
|
|
10
10
|
export declare function appendAudit(event: AuditEvent, payload: Record<string, unknown>, actor?: string): AuditRecord;
|
|
11
|
-
|
|
11
|
+
/** Archived segment filenames in chain order (oldest first). */
|
|
12
|
+
export declare function listSegments(): string[];
|
|
13
|
+
export interface ReadOptions {
|
|
14
|
+
/** Include archived segments. Default true ā callers asking for "the audit log" mean
|
|
15
|
+
* the whole history. Hot paths that only care about a recent window pass false. */
|
|
16
|
+
includeArchives?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function readAuditLog(opts?: ReadOptions): AuditRecord[];
|
|
19
|
+
export interface RotationPlan {
|
|
20
|
+
/** Records that would move to a segment. */
|
|
21
|
+
archiveCount: number;
|
|
22
|
+
/** Records that would stay in the live log. */
|
|
23
|
+
keepCount: number;
|
|
24
|
+
/** Timestamp cutoff: records strictly older than this are archived. */
|
|
25
|
+
cutoff: string;
|
|
26
|
+
segmentFile: string | null;
|
|
27
|
+
/** Set when the rotation must not run, with the reason. */
|
|
28
|
+
refusedReason: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface RotationResult extends RotationPlan {
|
|
31
|
+
rotated: boolean;
|
|
32
|
+
bytesArchived: number;
|
|
33
|
+
bytesRemaining: number;
|
|
34
|
+
}
|
|
35
|
+
export interface RotateOptions {
|
|
36
|
+
/** Archive records older than this many days. Minimum 1. */
|
|
37
|
+
keepDays?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Hard ceiling on how many records stay in the live log, whatever the dates say.
|
|
40
|
+
*
|
|
41
|
+
* š LOCKED [DATE-RETENTION-DOES-NOT-BOUND-SIZE] ā 2026-08-20
|
|
42
|
+
* ā NEVER ship rotation with a date rule alone.
|
|
43
|
+
* WHY: measured on the real log before shipping this ā at 80,000 records/day, a 30-day
|
|
44
|
+
* window left 390,445 records live and even a 3-day window left 205,422. Date
|
|
45
|
+
* retention bounds AGE, not SIZE, so on a busy machine it rotates and changes
|
|
46
|
+
* nothing that matters: readAuditLog() still costs seconds and hundreds of MB.
|
|
47
|
+
* The feature would have looked like it worked while leaving the problem in place.
|
|
48
|
+
* FIX: cut at whichever rule archives more, date or count. Count is what actually caps
|
|
49
|
+
* the file.
|
|
50
|
+
*/
|
|
51
|
+
maxRecords?: number;
|
|
52
|
+
/** Report what would happen and write nothing. */
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
now?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Plan a rotation without writing anything. Exported so the CLI's dry-run and the real
|
|
58
|
+
* run share one implementation and cannot disagree.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planRotation(opts?: RotateOptions): RotationPlan;
|
|
61
|
+
/**
|
|
62
|
+
* Move everything older than the cutoff into a numbered archive segment.
|
|
63
|
+
*
|
|
64
|
+
* Refuses to run on a chain that does not currently verify: rotating a log with altered
|
|
65
|
+
* or orphaned records would bake the damage into an append-only segment and make the
|
|
66
|
+
* cause unrecoverable. Forks are fine ā they are concurrency, not tampering.
|
|
67
|
+
*/
|
|
68
|
+
export declare function rotateAuditLog(opts?: RotateOptions): RotationResult;
|
|
12
69
|
export interface IntegrityReport {
|
|
13
70
|
ok: boolean;
|
|
14
71
|
total: number;
|
package/dist/audit.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// Records every state-changing operation. Each line carries the SHA-256 hash
|
|
47
47
|
// of the previous line's canonical content, so mutation of any historical
|
|
48
48
|
// record breaks chain verification at that index.
|
|
49
|
-
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, constants, } from "fs";
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, readdirSync, constants, } from "fs";
|
|
50
50
|
import { join } from "path";
|
|
51
51
|
import { homedir } from "os";
|
|
52
52
|
import { createHash } from "crypto";
|
|
@@ -180,12 +180,35 @@ function readLastHash() {
|
|
|
180
180
|
// Tail window held no complete record ā fall back to the full read.
|
|
181
181
|
return readLastHashFullScan();
|
|
182
182
|
}
|
|
183
|
+
return parseHeadOrThrow(lines[lines.length - 1]);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* š LOCKED [UNREADABLE-HEAD-IS-NOT-GENESIS] ā 2026-08-20
|
|
187
|
+
* ā NEVER return GENESIS_HASH because the last line failed to parse.
|
|
188
|
+
* WHY: both head readers ended in `catch { return GENESIS_HASH }`. A truncated or corrupt
|
|
189
|
+
* final record ā a partial write, a full disk, a killed process ā therefore made the
|
|
190
|
+
* next append chain onto genesis instead of onto the real head. verifyChain() reports
|
|
191
|
+
* that as an ORPHAN, i.e. "history was deleted", the hardest failure the log can
|
|
192
|
+
* produce, and it would be caused by the writer itself rather than by tampering.
|
|
193
|
+
* It is [ABSENCE-IS-NOT-A-VERDICT] on the bedrock path: "I could not read the head"
|
|
194
|
+
* was rendered as the specific, plausible claim "there is no history".
|
|
195
|
+
* FIX: throw. appendAudit() must surface problems loudly (see [AUDIT-CHAIN]); call sites
|
|
196
|
+
* that need isolation already use safeAppend(), which logs to stderr and continues.
|
|
197
|
+
*/
|
|
198
|
+
function parseHeadOrThrow(line) {
|
|
199
|
+
let rec;
|
|
183
200
|
try {
|
|
184
|
-
|
|
201
|
+
rec = JSON.parse(line);
|
|
185
202
|
}
|
|
186
203
|
catch {
|
|
187
|
-
|
|
204
|
+
throw new Error("Audit log tail is not valid JSON ā refusing to append onto an unknown head. " +
|
|
205
|
+
"Inspect the last line of ~/.contextengine/audit.log; a partial final record can be " +
|
|
206
|
+
"removed by hand, which verifyChain() will then confirm.");
|
|
188
207
|
}
|
|
208
|
+
if (typeof rec.hash !== "string" || rec.hash.length !== 64) {
|
|
209
|
+
throw new Error("Audit log tail has no usable hash ā refusing to append onto an unknown head.");
|
|
210
|
+
}
|
|
211
|
+
return rec.hash;
|
|
189
212
|
}
|
|
190
213
|
/** Fallback for the pathological case: a single record longer than TAIL_READ_BYTES. */
|
|
191
214
|
function readLastHashFullScan() {
|
|
@@ -194,12 +217,8 @@ function readLastHashFullScan() {
|
|
|
194
217
|
const lines = data.split("\n").filter(Boolean);
|
|
195
218
|
if (lines.length === 0)
|
|
196
219
|
return GENESIS_HASH;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
return GENESIS_HASH;
|
|
202
|
-
}
|
|
220
|
+
// [LOCK] [UNREADABLE-HEAD-IS-NOT-GENESIS] ā same rule as the tail reader.
|
|
221
|
+
return parseHeadOrThrow(lines[lines.length - 1]);
|
|
203
222
|
}
|
|
204
223
|
function computeHash(prevHash, ts, event, actor, payload) {
|
|
205
224
|
// Canonical serialization ā keys in fixed order so independent verifiers get
|
|
@@ -248,11 +267,43 @@ export function appendAudit(event, payload, actor = "system") {
|
|
|
248
267
|
release();
|
|
249
268
|
}
|
|
250
269
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
270
|
+
/**
|
|
271
|
+
* š LOCKED [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN] ā 2026-08-20
|
|
272
|
+
* ā NEVER rotate by truncating, moving or deleting audit.log. NEVER let a rotated log
|
|
273
|
+
* read as "history was deleted".
|
|
274
|
+
* WHY: verifyChain() classifies a record whose prev_hash names a hash absent from the log
|
|
275
|
+
* as an ORPHAN, which is a hard failure meaning deleted or truncated history ā the
|
|
276
|
+
* exact evidence claim SOC 2 CC7.2 / ISO 27001 A.12.4.1 rest on. A `mv audit.log
|
|
277
|
+
* audit.log.1` makes the very first record of the new file an orphan, so the naive
|
|
278
|
+
* rotation turns a healthy log into a permanent "TAMPERED" verdict for every future
|
|
279
|
+
* audit. The log reached 195 MB / 533,987 records with no rotation path precisely
|
|
280
|
+
* because the safe shape was never built.
|
|
281
|
+
* FIX: rotation MOVES a prefix of history into a numbered segment under audit-archive/
|
|
282
|
+
* and the canonical history is `segments in order ++ live log`. readAuditLog() reads
|
|
283
|
+
* that concatenation by default, so the chain stays linear and verification is
|
|
284
|
+
* unchanged. Segments are append-only and never rewritten.
|
|
285
|
+
*
|
|
286
|
+
* š LOCKED [ROTATE-ARCHIVE-BEFORE-TRUNCATE] ā 2026-08-20
|
|
287
|
+
* ā NEVER truncate the live log before the segment file is durably renamed into place.
|
|
288
|
+
* WHY: the reverse order loses records permanently on a crash between the two steps.
|
|
289
|
+
* This order can only ever produce a DUPLICATE (records in both the segment and the
|
|
290
|
+
* live log), which the seam de-dup below removes and which loses nothing.
|
|
291
|
+
* FIX: write segment tmp ā fsync ā rename ā write live remainder tmp ā fsync ā rename.
|
|
292
|
+
*/
|
|
293
|
+
function archiveDir() {
|
|
294
|
+
return join(auditDir(), "audit-archive");
|
|
295
|
+
}
|
|
296
|
+
const SEGMENT_RE = /^audit-(\d{4,})\.jsonl$/;
|
|
297
|
+
/** Archived segment filenames in chain order (oldest first). */
|
|
298
|
+
export function listSegments() {
|
|
299
|
+
const dir = archiveDir();
|
|
300
|
+
if (!existsSync(dir))
|
|
254
301
|
return [];
|
|
255
|
-
|
|
302
|
+
return readdirSync(dir)
|
|
303
|
+
.filter((f) => SEGMENT_RE.test(f))
|
|
304
|
+
.sort((a, b) => Number(SEGMENT_RE.exec(a)[1]) - Number(SEGMENT_RE.exec(b)[1]));
|
|
305
|
+
}
|
|
306
|
+
function parseLines(data, label) {
|
|
256
307
|
return data
|
|
257
308
|
.split("\n")
|
|
258
309
|
.filter(Boolean)
|
|
@@ -261,10 +312,176 @@ export function readAuditLog() {
|
|
|
261
312
|
return JSON.parse(line);
|
|
262
313
|
}
|
|
263
314
|
catch {
|
|
264
|
-
throw new Error(`Corrupt audit line ${i + 1}: not valid JSON`);
|
|
315
|
+
throw new Error(`Corrupt audit line ${i + 1} in ${label}: not valid JSON`);
|
|
265
316
|
}
|
|
266
317
|
});
|
|
267
318
|
}
|
|
319
|
+
export function readAuditLog(opts = {}) {
|
|
320
|
+
const includeArchives = opts.includeArchives !== false;
|
|
321
|
+
const path = auditPath();
|
|
322
|
+
const live = existsSync(path) ? parseLines(readFileSync(path, "utf-8"), "audit.log") : [];
|
|
323
|
+
if (!includeArchives)
|
|
324
|
+
return live;
|
|
325
|
+
const segments = listSegments();
|
|
326
|
+
if (segments.length === 0)
|
|
327
|
+
return live;
|
|
328
|
+
const history = [];
|
|
329
|
+
let lastSegmentHashes = new Set();
|
|
330
|
+
for (const f of segments) {
|
|
331
|
+
const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f);
|
|
332
|
+
// š LOCKED [NO-SPREAD-OVER-A-SEGMENT] ā 2026-08-20
|
|
333
|
+
// ā NEVER use push(...records) on a segment. Found on the first real rotation:
|
|
334
|
+
// a 494,152-record segment threw "Maximum call stack size exceeded" because the
|
|
335
|
+
// spread passes every element as a separate argument. Every unit test passed ā
|
|
336
|
+
// they used chains of a few thousand. Push in a loop, whatever the size.
|
|
337
|
+
for (const r of recs)
|
|
338
|
+
history.push(r);
|
|
339
|
+
lastSegmentHashes = new Set(recs.map((r) => r.hash));
|
|
340
|
+
}
|
|
341
|
+
// Seam de-dup ā see [ROTATE-ARCHIVE-BEFORE-TRUNCATE]. A crash after the segment was
|
|
342
|
+
// renamed but before the live log was truncated leaves the archived prefix present in
|
|
343
|
+
// both files. Drop only the LEADING run of live records already in the last segment;
|
|
344
|
+
// anything else is real history and must never be dropped.
|
|
345
|
+
let start = 0;
|
|
346
|
+
while (start < live.length && lastSegmentHashes.has(live[start].hash))
|
|
347
|
+
start++;
|
|
348
|
+
// [LOCK] [NO-SPREAD-OVER-A-SEGMENT] ā same reason.
|
|
349
|
+
for (let i = start; i < live.length; i++)
|
|
350
|
+
history.push(live[i]);
|
|
351
|
+
return history;
|
|
352
|
+
}
|
|
353
|
+
/** Never archive below this many most-recent records, whatever the date cutoff says.
|
|
354
|
+
* The live log has to keep enough context for the drift detectors' window. */
|
|
355
|
+
const MIN_LIVE_RECORDS = 2000;
|
|
356
|
+
/** Live-log ceiling when the caller does not set one. ~50k records ā 18 MB. */
|
|
357
|
+
const DEFAULT_MAX_LIVE_RECORDS = 50_000;
|
|
358
|
+
/**
|
|
359
|
+
* Plan a rotation without writing anything. Exported so the CLI's dry-run and the real
|
|
360
|
+
* run share one implementation and cannot disagree.
|
|
361
|
+
*/
|
|
362
|
+
export function planRotation(opts = {}) {
|
|
363
|
+
const keepDays = Math.max(1, Math.floor(opts.keepDays ?? 30));
|
|
364
|
+
const now = opts.now ?? Date.now();
|
|
365
|
+
const cutoff = new Date(now - keepDays * 86_400_000).toISOString();
|
|
366
|
+
const live = existsSync(auditPath())
|
|
367
|
+
? parseLines(readFileSync(auditPath(), "utf-8"), "audit.log")
|
|
368
|
+
: [];
|
|
369
|
+
let cutByDate = live.findIndex((r) => r.ts >= cutoff);
|
|
370
|
+
if (cutByDate === -1)
|
|
371
|
+
cutByDate = live.length; // every record is older than the cutoff
|
|
372
|
+
// [DATE-RETENTION-DOES-NOT-BOUND-SIZE] ā whichever rule archives more wins.
|
|
373
|
+
const maxRecords = Math.max(1, Math.floor(opts.maxRecords ?? DEFAULT_MAX_LIVE_RECORDS));
|
|
374
|
+
const cutByCount = Math.max(0, live.length - maxRecords);
|
|
375
|
+
let cut = Math.max(cutByDate, cutByCount);
|
|
376
|
+
// Keep the tail intact regardless of either rule.
|
|
377
|
+
cut = Math.min(cut, Math.max(0, live.length - MIN_LIVE_RECORDS));
|
|
378
|
+
const next = listSegments().length + 1;
|
|
379
|
+
return {
|
|
380
|
+
archiveCount: cut,
|
|
381
|
+
keepCount: live.length - cut,
|
|
382
|
+
cutoff,
|
|
383
|
+
segmentFile: cut > 0 ? `audit-${String(next).padStart(4, "0")}.jsonl` : null,
|
|
384
|
+
refusedReason: null,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Move everything older than the cutoff into a numbered archive segment.
|
|
389
|
+
*
|
|
390
|
+
* Refuses to run on a chain that does not currently verify: rotating a log with altered
|
|
391
|
+
* or orphaned records would bake the damage into an append-only segment and make the
|
|
392
|
+
* cause unrecoverable. Forks are fine ā they are concurrency, not tampering.
|
|
393
|
+
*/
|
|
394
|
+
export function rotateAuditLog(opts = {}) {
|
|
395
|
+
const path = auditPath();
|
|
396
|
+
const plan = planRotation(opts);
|
|
397
|
+
const empty = { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
|
|
398
|
+
if (!existsSync(path)) {
|
|
399
|
+
return { ...empty, refusedReason: "no audit log on disk" };
|
|
400
|
+
}
|
|
401
|
+
if (plan.archiveCount === 0) {
|
|
402
|
+
return {
|
|
403
|
+
...empty,
|
|
404
|
+
bytesRemaining: statSync(path).size,
|
|
405
|
+
refusedReason: `nothing to archive: ${plan.keepCount} record(s) live, within both the retention window and the size ceiling`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const integrity = verifyChain();
|
|
409
|
+
if (!integrity.ok) {
|
|
410
|
+
return {
|
|
411
|
+
...empty,
|
|
412
|
+
refusedReason: `chain does not verify (${integrity.breakReason}) ā refusing to archive a damaged log`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (opts.dryRun)
|
|
416
|
+
return { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
|
|
417
|
+
ensureDir();
|
|
418
|
+
const adir = archiveDir();
|
|
419
|
+
if (!existsSync(adir))
|
|
420
|
+
mkdirSync(adir, { recursive: true });
|
|
421
|
+
// Snapshot outside the lock: parsing 500k records is far too slow to hold the append
|
|
422
|
+
// lock for, and acquireLockSync() force-breaks locks older than STALE_LOCK_MS.
|
|
423
|
+
const snapshotSize = statSync(path).size;
|
|
424
|
+
const live = parseLines(readFileSync(path, "utf-8"), "audit.log");
|
|
425
|
+
const archived = live.slice(0, plan.archiveCount);
|
|
426
|
+
const remainder = live.slice(plan.archiveCount);
|
|
427
|
+
const segName = plan.segmentFile;
|
|
428
|
+
const segTmp = join(adir, `.${segName}.tmp`);
|
|
429
|
+
const segBody = archived.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
430
|
+
writeFileAndSync(segTmp, segBody);
|
|
431
|
+
renameSync(segTmp, join(adir, segName));
|
|
432
|
+
// [ROTATE-ARCHIVE-BEFORE-TRUNCATE]: the segment is durable from here on. Only now may
|
|
433
|
+
// the live log shrink.
|
|
434
|
+
const release = acquireLockSync();
|
|
435
|
+
let remainderBody = remainder.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
436
|
+
try {
|
|
437
|
+
const currentSize = statSync(path).size;
|
|
438
|
+
if (currentSize > snapshotSize) {
|
|
439
|
+
// Appends landed while we were writing the segment. They are newer than the cutoff
|
|
440
|
+
// by construction, so they belong to the remainder. Copy the raw bytes across
|
|
441
|
+
// rather than re-parsing the whole file.
|
|
442
|
+
const fd = openSync(path, constants.O_RDONLY);
|
|
443
|
+
try {
|
|
444
|
+
const buf = Buffer.alloc(currentSize - snapshotSize);
|
|
445
|
+
readSync(fd, buf, 0, buf.length, snapshotSize);
|
|
446
|
+
remainderBody += buf.toString("utf-8");
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
closeSync(fd);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const liveTmp = join(auditDir(), ".audit.log.tmp");
|
|
453
|
+
writeFileAndSync(liveTmp, remainderBody);
|
|
454
|
+
renameSync(liveTmp, path);
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
release();
|
|
458
|
+
}
|
|
459
|
+
// Self-documenting evidence: the rotation itself is an audited event, chained onto the
|
|
460
|
+
// new head like any other record.
|
|
461
|
+
appendAudit("audit.rotate", {
|
|
462
|
+
segment: segName,
|
|
463
|
+
archived_records: archived.length,
|
|
464
|
+
first_hash: archived[0].hash,
|
|
465
|
+
last_hash: archived[archived.length - 1].hash,
|
|
466
|
+
cutoff: plan.cutoff,
|
|
467
|
+
}, "system");
|
|
468
|
+
return {
|
|
469
|
+
...plan,
|
|
470
|
+
rotated: true,
|
|
471
|
+
bytesArchived: Buffer.byteLength(segBody),
|
|
472
|
+
bytesRemaining: statSync(path).size,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function writeFileAndSync(target, body) {
|
|
476
|
+
const fd = openSync(target, "w");
|
|
477
|
+
try {
|
|
478
|
+
writeSync(fd, body);
|
|
479
|
+
fsyncSync(fd);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
closeSync(fd);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
268
485
|
/**
|
|
269
486
|
* š LOCKED [VERIFY-FORK-IS-NOT-TAMPER] ā 2026-08-17
|
|
270
487
|
* ā NEVER report a forked chain as "the log was edited", and never stop at the first
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* š LOCKED [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā 2026-08-20
|
|
3
|
+
* ā NEVER route an unrecognised argv[2] to the MCP server again. The MCP server starts
|
|
4
|
+
* ONLY on a bare invocation, or on the explicit `serve` alias.
|
|
5
|
+
* WHY: `cli.ts` dispatched with a long if/else chain ending in `else { import("./index.js") }`,
|
|
6
|
+
* so ANY unknown token started a stdio server that silently waits on stdin. A typo
|
|
7
|
+
* (`contextengine scor`), a flag-first invocation (`contextengine --version`) or a
|
|
8
|
+
* renamed subcommand produced no error, no exit code, and no output ā it hung.
|
|
9
|
+
* This is [ABSENCE-IS-NOT-A-VERDICT] at the dispatch layer: "I do not recognise this"
|
|
10
|
+
* was rendered as "start the default mode", a plausible action chosen from a branch
|
|
11
|
+
* that had determined nothing. It has already cost a wrong finding: SESSION_22 §E3
|
|
12
|
+
* recorded "check_ports is ungated on the CLI" when there is no `check-ports` command
|
|
13
|
+
* at all ā what got measured was an MCP server booting.
|
|
14
|
+
* FIX: KNOWN_COMMANDS below is the single source of truth. Unknown token ā name it on
|
|
15
|
+
* stderr, suggest the nearest command, exit 1. A parity test asserts this list matches
|
|
16
|
+
* the literals the dispatcher actually handles, so the two cannot drift.
|
|
17
|
+
*/
|
|
18
|
+
/** Every token `cli.ts` dispatches on, including flag-style aliases. */
|
|
19
|
+
export declare const KNOWN_COMMANDS: readonly string[];
|
|
20
|
+
/** Commands that start the stdio MCP server. A bare invocation (argv[2] undefined)
|
|
21
|
+
* does the same ā that is the documented default and every launcher on disk uses it. */
|
|
22
|
+
export declare const SERVER_COMMANDS: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Closest known commands to `input`, nearest first, at most `limit`.
|
|
25
|
+
* Returns [] when nothing is close enough ā an empty suggestion list is honest,
|
|
26
|
+
* a wrong suggestion is not.
|
|
27
|
+
*/
|
|
28
|
+
export declare function suggestCommands(input: string, limit?: number): string[];
|
|
29
|
+
export declare function isKnownCommand(token: string): boolean;
|
|
30
|
+
//# sourceMappingURL=cli-commands.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* š LOCKED [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā 2026-08-20
|
|
3
|
+
* ā NEVER route an unrecognised argv[2] to the MCP server again. The MCP server starts
|
|
4
|
+
* ONLY on a bare invocation, or on the explicit `serve` alias.
|
|
5
|
+
* WHY: `cli.ts` dispatched with a long if/else chain ending in `else { import("./index.js") }`,
|
|
6
|
+
* so ANY unknown token started a stdio server that silently waits on stdin. A typo
|
|
7
|
+
* (`contextengine scor`), a flag-first invocation (`contextengine --version`) or a
|
|
8
|
+
* renamed subcommand produced no error, no exit code, and no output ā it hung.
|
|
9
|
+
* This is [ABSENCE-IS-NOT-A-VERDICT] at the dispatch layer: "I do not recognise this"
|
|
10
|
+
* was rendered as "start the default mode", a plausible action chosen from a branch
|
|
11
|
+
* that had determined nothing. It has already cost a wrong finding: SESSION_22 §E3
|
|
12
|
+
* recorded "check_ports is ungated on the CLI" when there is no `check-ports` command
|
|
13
|
+
* at all ā what got measured was an MCP server booting.
|
|
14
|
+
* FIX: KNOWN_COMMANDS below is the single source of truth. Unknown token ā name it on
|
|
15
|
+
* stderr, suggest the nearest command, exit 1. A parity test asserts this list matches
|
|
16
|
+
* the literals the dispatcher actually handles, so the two cannot drift.
|
|
17
|
+
*/
|
|
18
|
+
/** Every token `cli.ts` dispatches on, including flag-style aliases. */
|
|
19
|
+
export const KNOWN_COMMANDS = [
|
|
20
|
+
"--help",
|
|
21
|
+
"--version",
|
|
22
|
+
"-h",
|
|
23
|
+
"-v",
|
|
24
|
+
"activate",
|
|
25
|
+
"audit",
|
|
26
|
+
"audit-export",
|
|
27
|
+
"audit-rotate",
|
|
28
|
+
"audit-verify",
|
|
29
|
+
"autostart-status",
|
|
30
|
+
"cost",
|
|
31
|
+
"deactivate",
|
|
32
|
+
"delete-learning",
|
|
33
|
+
"delete-session",
|
|
34
|
+
"emit-event",
|
|
35
|
+
"end-session",
|
|
36
|
+
"export-learnings",
|
|
37
|
+
"help",
|
|
38
|
+
"hook",
|
|
39
|
+
"import-learnings",
|
|
40
|
+
"init",
|
|
41
|
+
"init-extension-secret",
|
|
42
|
+
"install-autostart",
|
|
43
|
+
"install-claude-hook",
|
|
44
|
+
"install-skill",
|
|
45
|
+
"list-learnings",
|
|
46
|
+
"list-projects",
|
|
47
|
+
"list-sessions",
|
|
48
|
+
"list-sources",
|
|
49
|
+
"load-session",
|
|
50
|
+
"policy",
|
|
51
|
+
"save-learning",
|
|
52
|
+
"save-session",
|
|
53
|
+
"score",
|
|
54
|
+
"search",
|
|
55
|
+
"serve",
|
|
56
|
+
"stats",
|
|
57
|
+
"status",
|
|
58
|
+
"sync-claude-md",
|
|
59
|
+
"sync-community-rules",
|
|
60
|
+
"uninstall-autostart",
|
|
61
|
+
"uninstall-claude-hook",
|
|
62
|
+
"version",
|
|
63
|
+
"watch",
|
|
64
|
+
];
|
|
65
|
+
/** Commands that start the stdio MCP server. A bare invocation (argv[2] undefined)
|
|
66
|
+
* does the same ā that is the documented default and every launcher on disk uses it. */
|
|
67
|
+
export const SERVER_COMMANDS = ["serve"];
|
|
68
|
+
/** Levenshtein distance, capped early ā only used to build a "did you mean" line. */
|
|
69
|
+
function editDistance(a, b) {
|
|
70
|
+
const m = a.length;
|
|
71
|
+
const n = b.length;
|
|
72
|
+
if (Math.abs(m - n) > 4)
|
|
73
|
+
return 99;
|
|
74
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
75
|
+
for (let i = 1; i <= m; i++) {
|
|
76
|
+
const cur = [i];
|
|
77
|
+
for (let j = 1; j <= n; j++) {
|
|
78
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
79
|
+
}
|
|
80
|
+
prev = cur;
|
|
81
|
+
}
|
|
82
|
+
return prev[n];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Closest known commands to `input`, nearest first, at most `limit`.
|
|
86
|
+
* Returns [] when nothing is close enough ā an empty suggestion list is honest,
|
|
87
|
+
* a wrong suggestion is not.
|
|
88
|
+
*/
|
|
89
|
+
export function suggestCommands(input, limit = 3) {
|
|
90
|
+
const candidates = KNOWN_COMMANDS.filter((c) => !c.startsWith("-"));
|
|
91
|
+
const scored = candidates
|
|
92
|
+
.map((c) => ({ c, d: editDistance(input.toLowerCase(), c) }))
|
|
93
|
+
// A prefix match is always relevant however long the tail ("audit" ā "audit-export").
|
|
94
|
+
.map((s) => ({ ...s, d: s.c.startsWith(input.toLowerCase()) ? Math.min(s.d, 2) : s.d }))
|
|
95
|
+
.filter((s) => s.d <= 3)
|
|
96
|
+
.sort((a, b) => a.d - b.d || a.c.localeCompare(b.c));
|
|
97
|
+
return scored.slice(0, limit).map((s) => s.c);
|
|
98
|
+
}
|
|
99
|
+
export function isKnownCommand(token) {
|
|
100
|
+
return KNOWN_COMMANDS.includes(token);
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=cli-commands.js.map
|
package/dist/cli.js
CHANGED
|
@@ -593,6 +593,7 @@ async function runInit() {
|
|
|
593
593
|
import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot, looksLikePath } from "./config.js";
|
|
594
594
|
import { ingestSources } from "./ingest.js";
|
|
595
595
|
import { searchChunks } from "./search.js";
|
|
596
|
+
import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
|
|
596
597
|
import { collectProjectOps, collectSystemOps } from "./collectors.js";
|
|
597
598
|
import { scanCodeDir } from "./code-chunker.js";
|
|
598
599
|
import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
|
|
@@ -600,7 +601,7 @@ import { listLearnings, learningsToChunks, learningsStats, formatLearnings, save
|
|
|
600
601
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
601
602
|
import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
|
|
602
603
|
import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
|
|
603
|
-
import { readAuditLog, verifyChain, filterByRange, toCsv, } from "./audit.js";
|
|
604
|
+
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, } from "./audit.js";
|
|
604
605
|
import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
|
|
605
606
|
import { collectRuns, metricsFor, transcriptRoot, emptyTally, addTally, totalTokens, pricingStatus, pricingFor, } from "./transcript-collector.js";
|
|
606
607
|
import { DEFAULT_PRICING, DEFAULT_PRICING_ASOF } from "./default-pricing.js";
|
|
@@ -1857,6 +1858,65 @@ reviewed, and validated in PR ahead of the hook wiring.`);
|
|
|
1857
1858
|
console.error(`Unknown subcommand: ${sub}. Try 'contextengine policy --help'.`);
|
|
1858
1859
|
process.exit(1);
|
|
1859
1860
|
}
|
|
1861
|
+
function fmtBytes(n) {
|
|
1862
|
+
if (n >= 1024 ** 3)
|
|
1863
|
+
return `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
1864
|
+
if (n >= 1024 ** 2)
|
|
1865
|
+
return `${(n / 1024 ** 2).toFixed(1)} MB`;
|
|
1866
|
+
if (n >= 1024)
|
|
1867
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1868
|
+
return `${n} B`;
|
|
1869
|
+
}
|
|
1870
|
+
function cliAuditRotate(args) {
|
|
1871
|
+
const keepIdx = args.findIndex((a) => a === "--keep-days");
|
|
1872
|
+
const keepDays = keepIdx >= 0 ? Number(args[keepIdx + 1]) : 30;
|
|
1873
|
+
const maxIdx = args.findIndex((a) => a === "--max-records");
|
|
1874
|
+
const maxRecords = maxIdx >= 0 ? Number(args[maxIdx + 1]) : undefined;
|
|
1875
|
+
const dryRun = args.includes("--dry-run");
|
|
1876
|
+
if (!Number.isFinite(keepDays) || keepDays < 1) {
|
|
1877
|
+
console.error("--keep-days must be a number >= 1.");
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
if (maxIdx >= 0 && (!Number.isFinite(maxRecords) || maxRecords < 1)) {
|
|
1881
|
+
console.error("--max-records must be a number >= 1.");
|
|
1882
|
+
process.exit(1);
|
|
1883
|
+
}
|
|
1884
|
+
if (dryRun) {
|
|
1885
|
+
const plan = planRotation({ keepDays, maxRecords });
|
|
1886
|
+
console.log(`\nš¦ Audit log rotation ā DRY RUN, nothing written\n`);
|
|
1887
|
+
console.log(` cutoff records older than ${plan.cutoff}`);
|
|
1888
|
+
console.log(` size ceiling ${maxRecords ?? 50000} record(s) kept live at most`);
|
|
1889
|
+
console.log(` would archive ${plan.archiveCount} record(s) ā ${plan.segmentFile ?? "(nothing)"}`);
|
|
1890
|
+
console.log(` would keep live ${plan.keepCount} record(s)`);
|
|
1891
|
+
console.log(`\n Run again without --dry-run to perform it.\n`);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
const result = rotateAuditLog({ keepDays, maxRecords });
|
|
1895
|
+
if (!result.rotated) {
|
|
1896
|
+
console.log(`\nNothing rotated: ${result.refusedReason}\n`);
|
|
1897
|
+
// Refusing because the chain is damaged is a failure, not a no-op.
|
|
1898
|
+
if (result.refusedReason?.includes("does not verify"))
|
|
1899
|
+
process.exit(2);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
console.log(`\nš¦ Audit log rotated\n`);
|
|
1903
|
+
console.log(` archived ${result.archiveCount} record(s) ā audit-archive/${result.segmentFile}`);
|
|
1904
|
+
console.log(` segment size ${fmtBytes(result.bytesArchived)}`);
|
|
1905
|
+
console.log(` live log now ${result.keepCount + 1} record(s), ${fmtBytes(result.bytesRemaining)}`);
|
|
1906
|
+
console.log(` segments on disk ${listSegments().length}`);
|
|
1907
|
+
console.log(`\n History is unchanged: archived segments are part of the chain, and`);
|
|
1908
|
+
console.log(` 'audit-verify' reads them. Do not delete or edit them ā that is the`);
|
|
1909
|
+
console.log(` one action that would turn this into missing history.\n`);
|
|
1910
|
+
const after = verifyChain();
|
|
1911
|
+
if (after.ok) {
|
|
1912
|
+
console.log(` ā
post-rotation verify: ${after.total} record(s), chain intact.\n`);
|
|
1913
|
+
}
|
|
1914
|
+
else {
|
|
1915
|
+
console.error(` ā post-rotation verify FAILED: ${after.breakReason}`);
|
|
1916
|
+
console.error(` The segment is on disk and nothing was deleted. Do not rotate again.\n`);
|
|
1917
|
+
process.exit(2);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1860
1920
|
async function cliAuditVerify() {
|
|
1861
1921
|
const report = verifyChain();
|
|
1862
1922
|
const forks = report.forkIndices ?? [];
|
|
@@ -2478,6 +2538,17 @@ async function cliCost(argv) {
|
|
|
2478
2538
|
}
|
|
2479
2539
|
console.log("");
|
|
2480
2540
|
}
|
|
2541
|
+
/** Package version, read from the installed package.json rather than hardcoded. */
|
|
2542
|
+
function readPackageVersion() {
|
|
2543
|
+
try {
|
|
2544
|
+
const here = new URL("../package.json", import.meta.url);
|
|
2545
|
+
return JSON.parse(readFileSync(here, "utf-8")).version;
|
|
2546
|
+
}
|
|
2547
|
+
catch {
|
|
2548
|
+
// No plausible-looking fallback here: an unknown version must read as unknown.
|
|
2549
|
+
return "unknown";
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2481
2552
|
// ---------------------------------------------------------------------------
|
|
2482
2553
|
// Main ā route to init, CLI subcommand, or MCP server
|
|
2483
2554
|
// ---------------------------------------------------------------------------
|
|
@@ -2515,6 +2586,12 @@ Usage:
|
|
|
2515
2586
|
Export hash-chained audit log (evidence aligned with
|
|
2516
2587
|
SOC 2 CC7.2 + ISO 27001 A.12.4.1 ā not a certification)
|
|
2517
2588
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
2589
|
+
contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
|
|
2590
|
+
Move old history into an archive segment. Archives
|
|
2591
|
+
whatever is older than N days (default 30) OR beyond
|
|
2592
|
+
the size ceiling (default 50000 live records),
|
|
2593
|
+
whichever is more. The chain stays linear: segments
|
|
2594
|
+
are part of the verified history, never deleted.
|
|
2518
2595
|
contextengine cost [--session ID] [--project NAME] [--run wf_ID] [--days N] [--top N] [--json]
|
|
2519
2596
|
Multi-agent spend from Claude Code transcripts. Always prints
|
|
2520
2597
|
VOLUME (tokens), VALUED COST (API list prices ā notional on a
|
|
@@ -2714,6 +2791,9 @@ else if (command === "sync-claude-md") {
|
|
|
2714
2791
|
process.exit(1);
|
|
2715
2792
|
});
|
|
2716
2793
|
}
|
|
2794
|
+
else if (command === "audit-rotate") {
|
|
2795
|
+
cliAuditRotate(process.argv.slice(3));
|
|
2796
|
+
}
|
|
2717
2797
|
else if (command === "audit-verify") {
|
|
2718
2798
|
cliAuditVerify().catch((err) => {
|
|
2719
2799
|
console.error("Error:", err);
|
|
@@ -2825,8 +2905,25 @@ else if (command === "status") {
|
|
|
2825
2905
|
}
|
|
2826
2906
|
console.log("");
|
|
2827
2907
|
}
|
|
2828
|
-
else {
|
|
2829
|
-
//
|
|
2908
|
+
else if (command === undefined || SERVER_COMMANDS.includes(command)) {
|
|
2909
|
+
// Documented default: a bare invocation (or the explicit `serve` alias) starts the
|
|
2910
|
+
// stdio MCP server. Every launcher on this machine uses the bare form.
|
|
2830
2911
|
import("./index.js");
|
|
2831
2912
|
}
|
|
2913
|
+
else if (command === "--version" || command === "-v" || command === "version") {
|
|
2914
|
+
console.log(readPackageVersion());
|
|
2915
|
+
}
|
|
2916
|
+
else {
|
|
2917
|
+
// š [LOCK] [UNKNOWN-COMMAND-MUST-NOT-START-A-SERVER] ā see src/cli-commands.ts
|
|
2918
|
+
// An unrecognised token used to fall through to the MCP server, which then waited on
|
|
2919
|
+
// stdin forever: no error, no exit code, no output. Name it and fail instead.
|
|
2920
|
+
console.error(`Unknown command: ${command}`);
|
|
2921
|
+
const suggestions = suggestCommands(command);
|
|
2922
|
+
if (suggestions.length > 0) {
|
|
2923
|
+
console.error(`Did you mean: ${suggestions.join(", ")}?`);
|
|
2924
|
+
}
|
|
2925
|
+
console.error(`Run 'contextengine help' for the full list.`);
|
|
2926
|
+
console.error(`To start the MCP server, run 'contextengine' with no arguments.`);
|
|
2927
|
+
process.exit(1);
|
|
2928
|
+
}
|
|
2832
2929
|
//# sourceMappingURL=cli.js.map
|
package/dist/detector.js
CHANGED
|
@@ -25,7 +25,10 @@ import { homedir } from "os";
|
|
|
25
25
|
export function scanRecentEvents(windowSeconds = 300, now = Date.now()) {
|
|
26
26
|
let all;
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// Live log only. The window is minutes; archived segments are days old by
|
|
29
|
+
// construction (see [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN], MIN_LIVE_RECORDS floor),
|
|
30
|
+
// so reading them here would add the whole history to a hot path for zero hits.
|
|
31
|
+
all = readAuditLog({ includeArchives: false });
|
|
29
32
|
}
|
|
30
33
|
catch {
|
|
31
34
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.2",
|
|
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",
|