@compr/opscontext-mcp 2.5.3 → 2.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/activation.d.ts +0 -28
- package/dist/activation.js +27 -148
- package/dist/audit.d.ts +59 -1
- package/dist/audit.js +194 -4
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +78 -209
- package/dist/cost-report.d.ts +22 -0
- package/dist/cost-report.js +220 -0
- package/dist/index.js +59 -4
- package/dist/learnings.d.ts +14 -1
- package/dist/learnings.js +67 -4
- package/dist/tools-manifest.d.ts +2 -2
- package/dist/tools-manifest.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -539,7 +539,7 @@ Everything happens locally — search, scoring, learnings, sessions, embeddings.
|
|
|
539
539
|
| Email | Activation only | Tie the licence to an account |
|
|
540
540
|
| Package version | Activation only | Serve a compatible module bundle |
|
|
541
541
|
| Platform/arch (e.g., `darwin/arm64`) | Activation only | Compatibility check |
|
|
542
|
-
|
|
|
542
|
+
| Licence bundle version | Daily heartbeat | Compatibility marker carried in the signed licence |
|
|
543
543
|
|
|
544
544
|
That is the complete list. The activation request sends exactly six fields and the heartbeat exactly three — enforced by a lock comment in `src/activation.ts` that forbids adding a seventh field reflecting usage.
|
|
545
545
|
|
package/dist/activation.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export declare const PREMIUM_MODULES: readonly ["agents", "search-adv"];
|
|
2
1
|
export declare const PREMIUM_TOOLS: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
|
|
3
2
|
export interface LicenseInfo {
|
|
4
3
|
key: string;
|
|
@@ -17,33 +16,6 @@ export declare function activate(licenseKey: string, email: string): Promise<{
|
|
|
17
16
|
message: string;
|
|
18
17
|
plan?: string;
|
|
19
18
|
}>;
|
|
20
|
-
/**
|
|
21
|
-
* Check if delta modules are installed and valid.
|
|
22
|
-
*/
|
|
23
|
-
export declare function isDeltaInstalled(): boolean;
|
|
24
|
-
/**
|
|
25
|
-
* Version of the delta bundle currently cached on disk, or null if none/unreadable.
|
|
26
|
-
* Exported so callers can report the mismatch rather than guess at it.
|
|
27
|
-
*/
|
|
28
|
-
export declare function installedDeltaVersion(): string | null;
|
|
29
|
-
/**
|
|
30
|
-
* Dynamically import a delta module.
|
|
31
|
-
* Returns null if not activated, module missing, or the cached delta is stale.
|
|
32
|
-
*
|
|
33
|
-
* 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
|
|
34
|
-
* ⛔ NEVER import a delta module without checking its manifest version against this package.
|
|
35
|
-
* WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
|
|
36
|
-
* the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
|
|
37
|
-
* two months and three sessions of scorer fixes out of date. Because this function imported
|
|
38
|
-
* whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
|
|
39
|
-
* inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
|
|
40
|
-
* catch this — a stale delta carries its own stale canary and its own stale pins, so it
|
|
41
|
-
* passes against itself.
|
|
42
|
-
* FIX: refuse to load a delta whose version does not match the running package, and say so on
|
|
43
|
-
* stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
|
|
44
|
-
* applied to code delivery rather than to a check result.
|
|
45
|
-
*/
|
|
46
|
-
export declare function loadDeltaModule(name: string): Promise<any | null>;
|
|
47
19
|
export declare function heartbeat(): Promise<boolean>;
|
|
48
20
|
export declare function deactivate(): void;
|
|
49
21
|
export declare function getActivationStatus(): {
|
package/dist/activation.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// LOCKED — verified March 3 2026 — activation +
|
|
1
|
+
// LOCKED — verified March 3 2026 — activation + machine fingerprint + heartbeat
|
|
2
2
|
// DO NOT RE-AUDIT — E2E tested Feb 23 2026, all 4 Pro tools verified
|
|
3
|
+
//
|
|
4
|
+
// [LOCKED] [DELTA-RETIRED] — 2026-08-21
|
|
5
|
+
// [NEVER] reintroduce a client-side "delta bundle" (download, decrypt, cache, import premium code).
|
|
6
|
+
// WHY: from 0f12967 (2026-02-20) to 2.5.3 the client fetched an AES-encrypted bundle on activation,
|
|
7
|
+
// wrote it to ~/.contextengine/delta/, and never imported it: loadDeltaModule() had no caller,
|
|
8
|
+
// index.ts and cli.ts import agents.js / search.js / firewall.js from the package. Yet gateCheck
|
|
9
|
+
// refused premium tools when the unused cache was missing, and a stale cache needed its own
|
|
10
|
+
// guard (the former [DELTA-VERSION-PIN], 2026-08-14). Dead weight with live failure modes.
|
|
11
|
+
// FIX: the gate is the signed licence alone (Ed25519, machine-bound, expiry, daily heartbeat).
|
|
12
|
+
// The moat is the gate plus BSL-1.1, as CLAUDE.md rule 3 states. Retired on Yan's decision,
|
|
13
|
+
// SESSION_23. deactivate() still empties a legacy ~/.contextengine/delta/ so old caches go away.
|
|
3
14
|
/**
|
|
4
|
-
* Activation
|
|
15
|
+
* Activation System
|
|
5
16
|
*
|
|
6
17
|
* The npm package ships with core functionality (search, sessions, learnings,
|
|
7
18
|
* operational collectors). PRO unlocks the four high-value tools that consume
|
|
@@ -24,48 +35,29 @@
|
|
|
24
35
|
*
|
|
25
36
|
* On activation:
|
|
26
37
|
* 1. License key is validated against the ContextEngine API
|
|
27
|
-
* 2. Server returns a signed
|
|
28
|
-
* 3.
|
|
29
|
-
*
|
|
38
|
+
* 2. Server returns a signed licence (Ed25519), saved to ~/.contextengine/license.json
|
|
39
|
+
* 3. Premium tools become available
|
|
40
|
+
* The server may still include a `delta` field in its response; it is ignored. [LOCK] [DELTA-RETIRED]
|
|
30
41
|
*/
|
|
31
42
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
|
|
32
|
-
import { join
|
|
43
|
+
import { join } from "path";
|
|
33
44
|
import { homedir } from "os";
|
|
34
|
-
import {
|
|
35
|
-
import { createHash, createDecipheriv } from "crypto";
|
|
45
|
+
import { createHash } from "crypto";
|
|
36
46
|
import { safeAppend } from "./audit.js";
|
|
37
47
|
import { verifyLicenseSignature } from "./license-sig.js";
|
|
38
48
|
// ---------------------------------------------------------------------------
|
|
39
49
|
// Constants
|
|
40
50
|
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* Version of the running package. Read from package.json at module load, the same way
|
|
44
|
-
* agents.ts does it, so [DELTA-VERSION-PIN] compares against the real installed version
|
|
45
|
-
* rather than a constant someone forgets to bump.
|
|
46
|
-
*/
|
|
47
|
-
const PACKAGE_VERSION = (() => {
|
|
48
|
-
try {
|
|
49
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
|
-
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "unknown";
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return "unknown";
|
|
54
|
-
}
|
|
55
|
-
})();
|
|
51
|
+
// Legacy cache location, only ever cleaned now. [LOCK] [DELTA-RETIRED]
|
|
52
|
+
const LEGACY_DELTA_DIR = join(homedir(), ".contextengine", "delta");
|
|
56
53
|
const LICENSE_FILE = join(homedir(), ".contextengine", "license.json");
|
|
57
54
|
const ACTIVATION_API_BASE = process.env.CONTEXTENGINE_API || "https://api.compr.ch/contextengine";
|
|
58
55
|
const ACTIVATION_API = `${ACTIVATION_API_BASE}/activate`;
|
|
59
56
|
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily check
|
|
60
|
-
// Premium modules that require activation.
|
|
61
57
|
// NOTE: collectors.ts runs unconditionally during reindex for all users
|
|
62
|
-
// (operational data feeds search_context for everyone). The PRO tools
|
|
63
|
-
//
|
|
58
|
+
// (operational data feeds search_context for everyone). The PRO tools in
|
|
59
|
+
// PREMIUM_TOOLS are what consume that data for scoring/audit/cross-project
|
|
64
60
|
// reports. Keep the gate at the tool layer, not the data-collection layer.
|
|
65
|
-
export const PREMIUM_MODULES = [
|
|
66
|
-
"agents", // scorer, auditor, port checker, HTML report formatters
|
|
67
|
-
"search-adv", // advanced BM25 with tuned parameters
|
|
68
|
-
];
|
|
69
61
|
// Tools that require activation. Re-exported from the central manifest so
|
|
70
62
|
// the count and the name list have a SINGLE source of truth. Adding a new
|
|
71
63
|
// PRO tool requires editing src/tools-manifest.ts (which also feeds the
|
|
@@ -194,8 +186,6 @@ export async function activate(licenseKey, email) {
|
|
|
194
186
|
}
|
|
195
187
|
// Save license
|
|
196
188
|
saveLicense(data.license);
|
|
197
|
-
// Decrypt and store delta modules
|
|
198
|
-
await installDelta(data.delta, data.license.key);
|
|
199
189
|
safeAppend("activation.activate", {
|
|
200
190
|
plan: data.license.plan,
|
|
201
191
|
email: data.license.email,
|
|
@@ -214,112 +204,6 @@ export async function activate(licenseKey, email) {
|
|
|
214
204
|
}
|
|
215
205
|
}
|
|
216
206
|
// ---------------------------------------------------------------------------
|
|
217
|
-
// Delta module management
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
async function installDelta(delta, licenseKey) {
|
|
220
|
-
if (!existsSync(DELTA_DIR))
|
|
221
|
-
mkdirSync(DELTA_DIR, { recursive: true });
|
|
222
|
-
// Derive decryption key from license key
|
|
223
|
-
const derivedKey = createHash("sha256")
|
|
224
|
-
.update(licenseKey + getMachineId())
|
|
225
|
-
.digest();
|
|
226
|
-
const iv = Buffer.from(delta.iv, "hex");
|
|
227
|
-
for (const mod of delta.modules) {
|
|
228
|
-
const encrypted = Buffer.from(mod.payload, "base64");
|
|
229
|
-
// AES-256-CBC decrypt
|
|
230
|
-
const decipher = createDecipheriv("aes-256-cbc", derivedKey, iv);
|
|
231
|
-
let decrypted = decipher.update(encrypted);
|
|
232
|
-
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
233
|
-
const content = decrypted.toString("utf-8");
|
|
234
|
-
// Verify checksum
|
|
235
|
-
const checksum = createHash("sha256").update(content).digest("hex");
|
|
236
|
-
if (checksum !== mod.checksum) {
|
|
237
|
-
throw new Error(`Delta module ${mod.name} checksum mismatch — possible tampering`);
|
|
238
|
-
}
|
|
239
|
-
// Write to delta directory
|
|
240
|
-
writeFileSync(join(DELTA_DIR, `${mod.name}.mjs`), content);
|
|
241
|
-
}
|
|
242
|
-
// Write version marker
|
|
243
|
-
writeFileSync(join(DELTA_DIR, "manifest.json"), JSON.stringify({
|
|
244
|
-
version: delta.version,
|
|
245
|
-
installedAt: new Date().toISOString(),
|
|
246
|
-
modules: delta.modules.map((m) => m.name),
|
|
247
|
-
}));
|
|
248
|
-
console.error(`[ContextEngine] 📦 Delta v${delta.version} installed (${delta.modules.length} modules)`);
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Check if delta modules are installed and valid.
|
|
252
|
-
*/
|
|
253
|
-
export function isDeltaInstalled() {
|
|
254
|
-
const manifestPath = join(DELTA_DIR, "manifest.json");
|
|
255
|
-
if (!existsSync(manifestPath))
|
|
256
|
-
return false;
|
|
257
|
-
try {
|
|
258
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
259
|
-
// Verify all expected module files exist
|
|
260
|
-
for (const modName of manifest.modules) {
|
|
261
|
-
if (!existsSync(join(DELTA_DIR, `${modName}.mjs`)))
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Version of the delta bundle currently cached on disk, or null if none/unreadable.
|
|
272
|
-
* Exported so callers can report the mismatch rather than guess at it.
|
|
273
|
-
*/
|
|
274
|
-
export function installedDeltaVersion() {
|
|
275
|
-
try {
|
|
276
|
-
const manifest = JSON.parse(readFileSync(join(DELTA_DIR, "manifest.json"), "utf-8"));
|
|
277
|
-
return typeof manifest.version === "string" ? manifest.version : null;
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Dynamically import a delta module.
|
|
285
|
-
* Returns null if not activated, module missing, or the cached delta is stale.
|
|
286
|
-
*
|
|
287
|
-
* 🔒 LOCKED [DELTA-VERSION-PIN] — 2026-08-14
|
|
288
|
-
* ⛔ NEVER import a delta module without checking its manifest version against this package.
|
|
289
|
-
* WHY: the cache at ~/.contextengine/delta/ is written once at activation and never expires. On
|
|
290
|
-
* the author's own machine it held version 1.19.1 while the installed package was 2.3.1 —
|
|
291
|
-
* two months and three sessions of scorer fixes out of date. Because this function imported
|
|
292
|
-
* whatever .mjs happened to be on disk, wiring it up would have silently run the OLD scorer
|
|
293
|
-
* inside the NEW package: no error, no symptom, just quietly wrong scores. The canary cannot
|
|
294
|
-
* catch this — a stale delta carries its own stale canary and its own stale pins, so it
|
|
295
|
-
* passes against itself.
|
|
296
|
-
* FIX: refuse to load a delta whose version does not match the running package, and say so on
|
|
297
|
-
* stderr. A stale module is an unknown, not a usable one — [ABSENCE-IS-NOT-A-VERDICT]
|
|
298
|
-
* applied to code delivery rather than to a check result.
|
|
299
|
-
*/
|
|
300
|
-
export async function loadDeltaModule(name) {
|
|
301
|
-
if (!isDeltaInstalled())
|
|
302
|
-
return null;
|
|
303
|
-
const cached = installedDeltaVersion();
|
|
304
|
-
if (cached !== PACKAGE_VERSION) {
|
|
305
|
-
console.error(`[ContextEngine] ⚠ Delta module "${name}" is version ${cached ?? "unknown"} but this package is ` +
|
|
306
|
-
`${PACKAGE_VERSION} — refusing to load a stale module. Re-run \`contextengine activate\` to refresh.`);
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
const modulePath = join(DELTA_DIR, `${name}.mjs`);
|
|
310
|
-
if (!existsSync(modulePath))
|
|
311
|
-
return null;
|
|
312
|
-
try {
|
|
313
|
-
// Dynamic import of the decrypted module
|
|
314
|
-
const moduleUrl = `file://${modulePath}`;
|
|
315
|
-
return await import(moduleUrl);
|
|
316
|
-
}
|
|
317
|
-
catch (err) {
|
|
318
|
-
console.error(`[ContextEngine] ⚠ Failed to load delta module ${name}:`, err.message);
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
207
|
// Heartbeat — periodic license validation
|
|
324
208
|
// ---------------------------------------------------------------------------
|
|
325
209
|
export async function heartbeat() {
|
|
@@ -368,10 +252,10 @@ export function deactivate() {
|
|
|
368
252
|
// Remove license
|
|
369
253
|
if (existsSync(LICENSE_FILE))
|
|
370
254
|
unlinkSync(LICENSE_FILE);
|
|
371
|
-
// Remove delta
|
|
372
|
-
if (existsSync(
|
|
373
|
-
for (const file of readdirSync(
|
|
374
|
-
unlinkSync(join(
|
|
255
|
+
// Remove a legacy delta cache if one is still around. [LOCK] [DELTA-RETIRED]
|
|
256
|
+
if (existsSync(LEGACY_DELTA_DIR)) {
|
|
257
|
+
for (const file of readdirSync(LEGACY_DELTA_DIR)) {
|
|
258
|
+
unlinkSync(join(LEGACY_DELTA_DIR, file));
|
|
375
259
|
}
|
|
376
260
|
}
|
|
377
261
|
safeAppend("activation.deactivate", {
|
|
@@ -386,8 +270,7 @@ export function deactivate() {
|
|
|
386
270
|
// ---------------------------------------------------------------------------
|
|
387
271
|
export function getActivationStatus() {
|
|
388
272
|
const license = loadLicense();
|
|
389
|
-
|
|
390
|
-
if (!license || !deltaInstalled) {
|
|
273
|
+
if (!license) {
|
|
391
274
|
return {
|
|
392
275
|
activated: false,
|
|
393
276
|
plan: "community",
|
|
@@ -428,10 +311,6 @@ export function gateCheck(toolName) {
|
|
|
428
311
|
`save_session, load_session, list_sessions, end_session, save_learning, ` +
|
|
429
312
|
`list_learnings, import_learnings`;
|
|
430
313
|
}
|
|
431
|
-
if (!isDeltaInstalled()) {
|
|
432
|
-
return `🔒 Premium modules not installed. Re-activate:\n` +
|
|
433
|
-
`npx contextengine activate ${license.key} ${license.email}`;
|
|
434
|
-
}
|
|
435
314
|
return null;
|
|
436
315
|
}
|
|
437
316
|
// ---------------------------------------------------------------------------
|
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" | "audit.rotate";
|
|
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" | "audit.redact";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
|
@@ -66,6 +66,35 @@ export declare function planRotation(opts?: RotateOptions): RotationPlan;
|
|
|
66
66
|
* cause unrecoverable. Forks are fine — they are concurrency, not tampering.
|
|
67
67
|
*/
|
|
68
68
|
export declare function rotateAuditLog(opts?: RotateOptions): RotationResult;
|
|
69
|
+
/**
|
|
70
|
+
* Startup auto-rotation for the MCP server.
|
|
71
|
+
*
|
|
72
|
+
* [LOCKED] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER] — 2026-08-21
|
|
73
|
+
* [NEVER] trigger at the same count the rotation keeps, and never let two servers rotate at once.
|
|
74
|
+
* WHY: rotation was manual and the live log crossed the 50k ceiling in ~15h, so the drift
|
|
75
|
+
* detectors' per-tick read slowed by the day between hand runs. A startup hook fixes the
|
|
76
|
+
* cadence, but (a) triggering at 50k and rotating down to 50k would cut a handful of
|
|
77
|
+
* records into a new segment on every start, and (b) three MCP servers start together
|
|
78
|
+
* on this machine (VS Code, launchd, Claude Code); each would plan on the same oversized
|
|
79
|
+
* file and the late ones would archive records the first one had already kept.
|
|
80
|
+
* FIX: trigger at AUTO_ROTATE_TRIGGER (2x the ceiling), rotate down to the ceiling, so a
|
|
81
|
+
* rotation buys ~a day of quiet. A dedicated rotate lock (O_EXCL, stale after 10 min,
|
|
82
|
+
* long enough to verify a 500k-record chain) makes late starters return "in progress"
|
|
83
|
+
* without touching the log. Opt out with CONTEXTENGINE_AUTO_ROTATE=0.
|
|
84
|
+
*/
|
|
85
|
+
export declare const AUTO_ROTATE_TRIGGER: number;
|
|
86
|
+
/** Count newline-terminated lines without parsing. The live log is small by construction. */
|
|
87
|
+
export declare function countLiveRecords(): number;
|
|
88
|
+
export interface AutoRotateOutcome {
|
|
89
|
+
action: "disabled" | "below_trigger" | "in_progress" | "rotated" | "refused" | "error";
|
|
90
|
+
liveRecords: number;
|
|
91
|
+
detail: string;
|
|
92
|
+
result?: RotationResult;
|
|
93
|
+
}
|
|
94
|
+
export declare function autoRotateAuditLog(opts?: {
|
|
95
|
+
trigger?: number;
|
|
96
|
+
maxRecords?: number;
|
|
97
|
+
}): AutoRotateOutcome;
|
|
69
98
|
export interface IntegrityReport {
|
|
70
99
|
ok: boolean;
|
|
71
100
|
total: number;
|
|
@@ -79,6 +108,10 @@ export interface IntegrityReport {
|
|
|
79
108
|
/** Records whose prev_hash names a KNOWN earlier head — a concurrent-append fork.
|
|
80
109
|
* Content is provably intact; only the linkage is non-linear. Not tampering. */
|
|
81
110
|
forkIndices?: number[];
|
|
111
|
+
/** Records whose content was altered AND whose alteration is acknowledged by a later, intact
|
|
112
|
+
* `audit.redact` record binding the original hash to the current content. Not counted as
|
|
113
|
+
* tampering. */
|
|
114
|
+
redactedIndices?: number[];
|
|
82
115
|
}
|
|
83
116
|
/**
|
|
84
117
|
* 🔒 LOCKED [VERIFY-FORK-IS-NOT-TAMPER] — 2026-08-17
|
|
@@ -104,6 +137,31 @@ export interface IntegrityReport {
|
|
|
104
137
|
* never claim a forked log is pristine either.
|
|
105
138
|
*/
|
|
106
139
|
export declare function verifyChain(): IntegrityReport;
|
|
140
|
+
/**
|
|
141
|
+
* Acknowledge that records were deliberately redacted (a secret removed from their content).
|
|
142
|
+
*
|
|
143
|
+
* [LOCKED] [REDACTION-IS-A-CHAINED-RECORD] — 2026-08-21
|
|
144
|
+
* [NEVER] let the verifier accept an allow-list that lives outside the chain (a file, an env
|
|
145
|
+
* var, a CLI flag) as grounds to stop calling an altered record altered.
|
|
146
|
+
* WHY: on 2026-08-20 a credentials sweep replaced a password with [REDACTED_SECRET] in 3
|
|
147
|
+
* archived records. Correct, but the chain can only see "content no longer matches its
|
|
148
|
+
* hash", so the compliance report read "tampering" for a deliberate act nobody recorded.
|
|
149
|
+
* An out-of-band allow-list would fix the wording and destroy the property: anyone who
|
|
150
|
+
* can edit the log can edit the list.
|
|
151
|
+
* FIX: the acknowledgement is an `audit.redact` record, appended to the chain like any other,
|
|
152
|
+
* naming the original hash of each redacted record and the hash of its redacted content.
|
|
153
|
+
* It can only be written after the fact, only for records that are actually altered, and
|
|
154
|
+
* a further edit breaks the binding. The verifier reports such records as "redacted",
|
|
155
|
+
* counts them separately, and `ok` ignores them; everything else stays "altered".
|
|
156
|
+
*/
|
|
157
|
+
export declare function acknowledgeRedaction(indices: number[], reason: string, actor?: string): {
|
|
158
|
+
acknowledged: number[];
|
|
159
|
+
rejected: Array<{
|
|
160
|
+
index: number;
|
|
161
|
+
why: string;
|
|
162
|
+
}>;
|
|
163
|
+
record: AuditRecord | null;
|
|
164
|
+
};
|
|
107
165
|
export declare function filterByRange(records: AuditRecord[], since?: string, until?: string): AuditRecord[];
|
|
108
166
|
export declare function toCsv(records: AuditRecord[]): string;
|
|
109
167
|
export declare function resetCacheForTest(): void;
|
package/dist/audit.js
CHANGED
|
@@ -407,10 +407,28 @@ export function rotateAuditLog(opts = {}) {
|
|
|
407
407
|
}
|
|
408
408
|
const integrity = verifyChain();
|
|
409
409
|
if (!integrity.ok) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
410
|
+
// [LOCKED] [ROTATE-REFUSES-LIVE-DAMAGE-ONLY] — 2026-08-21
|
|
411
|
+
// [NEVER] refuse a rotation for damage that sits entirely inside segments already archived.
|
|
412
|
+
// WHY: on 2026-08-20 a credentials sweep replaced a password literal with [REDACTED_SECRET]
|
|
413
|
+
// in 3 records of audit-0001.jsonl. Correct, deliberate, and permanent: the verifier
|
|
414
|
+
// reports them as altered for good. A whole-chain refusal then blocks every future
|
|
415
|
+
// rotation, the live log grows without bound (170k records a day later, 3x the
|
|
416
|
+
// ceiling) and the detectors slow down again, which is the failure rotation exists to
|
|
417
|
+
// prevent. Archived segments are already immutable by policy; refusing to archive new
|
|
418
|
+
// records protects nothing there.
|
|
419
|
+
// FIX: refuse only when an altered or orphaned record is in the live log, the part about to
|
|
420
|
+
// be rewritten. Damage confined to segments is reported, not treated as a veto.
|
|
421
|
+
const firstLive = integrity.total - plan.archiveCount - plan.keepCount;
|
|
422
|
+
const bad = [...(integrity.tamperedIndices ?? []), ...(integrity.orphanIndices ?? [])];
|
|
423
|
+
const inLive = bad.filter((i) => i >= firstLive);
|
|
424
|
+
if (inLive.length > 0) {
|
|
425
|
+
return {
|
|
426
|
+
...empty,
|
|
427
|
+
refusedReason: `chain does not verify (${integrity.breakReason}) — ${inLive.length} damaged record(s) in the live log, refusing to archive a damaged log`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
console.error(`[ContextEngine] ⚠ audit rotation: ${bad.length} known damaged record(s) in archived segments ` +
|
|
431
|
+
`(first at ${bad[0]}); none in the live log, rotating.`);
|
|
414
432
|
}
|
|
415
433
|
if (opts.dryRun)
|
|
416
434
|
return { ...plan, rotated: false, bytesArchived: 0, bytesRemaining: 0 };
|
|
@@ -472,6 +490,99 @@ export function rotateAuditLog(opts = {}) {
|
|
|
472
490
|
bytesRemaining: statSync(path).size,
|
|
473
491
|
};
|
|
474
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Startup auto-rotation for the MCP server.
|
|
495
|
+
*
|
|
496
|
+
* [LOCKED] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER] — 2026-08-21
|
|
497
|
+
* [NEVER] trigger at the same count the rotation keeps, and never let two servers rotate at once.
|
|
498
|
+
* WHY: rotation was manual and the live log crossed the 50k ceiling in ~15h, so the drift
|
|
499
|
+
* detectors' per-tick read slowed by the day between hand runs. A startup hook fixes the
|
|
500
|
+
* cadence, but (a) triggering at 50k and rotating down to 50k would cut a handful of
|
|
501
|
+
* records into a new segment on every start, and (b) three MCP servers start together
|
|
502
|
+
* on this machine (VS Code, launchd, Claude Code); each would plan on the same oversized
|
|
503
|
+
* file and the late ones would archive records the first one had already kept.
|
|
504
|
+
* FIX: trigger at AUTO_ROTATE_TRIGGER (2x the ceiling), rotate down to the ceiling, so a
|
|
505
|
+
* rotation buys ~a day of quiet. A dedicated rotate lock (O_EXCL, stale after 10 min,
|
|
506
|
+
* long enough to verify a 500k-record chain) makes late starters return "in progress"
|
|
507
|
+
* without touching the log. Opt out with CONTEXTENGINE_AUTO_ROTATE=0.
|
|
508
|
+
*/
|
|
509
|
+
export const AUTO_ROTATE_TRIGGER = 2 * DEFAULT_MAX_LIVE_RECORDS;
|
|
510
|
+
const ROTATE_LOCK_STALE_MS = 10 * 60_000;
|
|
511
|
+
function rotateLockPath() {
|
|
512
|
+
return join(auditDir(), "audit.rotate.lock");
|
|
513
|
+
}
|
|
514
|
+
/** Count newline-terminated lines without parsing. The live log is small by construction. */
|
|
515
|
+
export function countLiveRecords() {
|
|
516
|
+
const path = auditPath();
|
|
517
|
+
if (!existsSync(path))
|
|
518
|
+
return 0;
|
|
519
|
+
const buf = readFileSync(path);
|
|
520
|
+
let n = 0;
|
|
521
|
+
for (let i = 0; i < buf.length; i++)
|
|
522
|
+
if (buf[i] === 10)
|
|
523
|
+
n++;
|
|
524
|
+
return n;
|
|
525
|
+
}
|
|
526
|
+
export function autoRotateAuditLog(opts = {}) {
|
|
527
|
+
const trigger = opts.trigger ?? AUTO_ROTATE_TRIGGER;
|
|
528
|
+
const maxRecords = opts.maxRecords ?? DEFAULT_MAX_LIVE_RECORDS;
|
|
529
|
+
if (process.env.CONTEXTENGINE_AUTO_ROTATE === "0") {
|
|
530
|
+
return { action: "disabled", liveRecords: -1, detail: "CONTEXTENGINE_AUTO_ROTATE=0" };
|
|
531
|
+
}
|
|
532
|
+
const liveRecords = countLiveRecords();
|
|
533
|
+
if (liveRecords <= trigger) {
|
|
534
|
+
return { action: "below_trigger", liveRecords, detail: `${liveRecords} live record(s), trigger is ${trigger}` };
|
|
535
|
+
}
|
|
536
|
+
// One runner at a time. O_EXCL create is the primitive; a stale file is an orphan from a
|
|
537
|
+
// crashed rotation, not a live one.
|
|
538
|
+
const lock = rotateLockPath();
|
|
539
|
+
let fd;
|
|
540
|
+
try {
|
|
541
|
+
ensureDir();
|
|
542
|
+
try {
|
|
543
|
+
fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
if (e.code !== "EEXIST")
|
|
547
|
+
throw e;
|
|
548
|
+
const age = Date.now() - statSync(lock).mtimeMs;
|
|
549
|
+
if (age < ROTATE_LOCK_STALE_MS) {
|
|
550
|
+
return { action: "in_progress", liveRecords, detail: `another rotation holds ${lock} (${Math.round(age / 1000)}s old)` };
|
|
551
|
+
}
|
|
552
|
+
unlinkSync(lock);
|
|
553
|
+
fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
return { action: "error", liveRecords, detail: `rotate lock: ${e.message}` };
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
try {
|
|
561
|
+
writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
|
|
562
|
+
}
|
|
563
|
+
catch { /* contents are a courtesy */ }
|
|
564
|
+
closeSync(fd);
|
|
565
|
+
const result = rotateAuditLog({ maxRecords });
|
|
566
|
+
if (!result.rotated) {
|
|
567
|
+
return { action: "refused", liveRecords, detail: result.refusedReason ?? "not rotated", result };
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
action: "rotated",
|
|
571
|
+
liveRecords,
|
|
572
|
+
detail: `archived ${result.archiveCount} record(s) to ${result.segmentFile}, ${result.keepCount + 1} live`,
|
|
573
|
+
result,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
catch (e) {
|
|
577
|
+
return { action: "error", liveRecords, detail: e.message };
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
try {
|
|
581
|
+
unlinkSync(lock);
|
|
582
|
+
}
|
|
583
|
+
catch { /* already gone */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
475
586
|
function writeFileAndSync(target, body) {
|
|
476
587
|
const fd = openSync(target, "w");
|
|
477
588
|
try {
|
|
@@ -543,6 +654,37 @@ export function verifyChain() {
|
|
|
543
654
|
seen.add(r.hash);
|
|
544
655
|
prev = r.hash;
|
|
545
656
|
}
|
|
657
|
+
// 3. Acknowledged redactions. [LOCK] [REDACTION-IS-A-CHAINED-RECORD]
|
|
658
|
+
// An `audit.redact` record that is itself intact binds (original hash -> hash of the
|
|
659
|
+
// redacted content). A tampered record matching such a binding is "redacted", not
|
|
660
|
+
// "altered". Binding to the current content means a second edit after the acknowledgement
|
|
661
|
+
// makes it tampered again.
|
|
662
|
+
const tamperedSet = new Set(tampered);
|
|
663
|
+
const acks = new Map();
|
|
664
|
+
for (let i = 0; i < records.length; i++) {
|
|
665
|
+
const r = records[i];
|
|
666
|
+
if (r.event !== "audit.redact" || tamperedSet.has(i))
|
|
667
|
+
continue;
|
|
668
|
+
const list = r.payload.redacted;
|
|
669
|
+
if (!Array.isArray(list))
|
|
670
|
+
continue;
|
|
671
|
+
for (const e of list) {
|
|
672
|
+
if (typeof e.hash === "string" && typeof e.content_hash === "string")
|
|
673
|
+
acks.set(e.hash, e.content_hash);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const redacted = [];
|
|
677
|
+
const stillTampered = [];
|
|
678
|
+
for (const i of tampered) {
|
|
679
|
+
const r = records[i];
|
|
680
|
+
const bound = acks.get(r.hash);
|
|
681
|
+
if (bound && bound === computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload))
|
|
682
|
+
redacted.push(i);
|
|
683
|
+
else
|
|
684
|
+
stillTampered.push(i);
|
|
685
|
+
}
|
|
686
|
+
tampered.length = 0;
|
|
687
|
+
tampered.push(...stillTampered);
|
|
546
688
|
const ok = tampered.length === 0 && orphans.length === 0;
|
|
547
689
|
const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
|
|
548
690
|
let reason = null;
|
|
@@ -560,8 +702,56 @@ export function verifyChain() {
|
|
|
560
702
|
tamperedIndices: tampered,
|
|
561
703
|
orphanIndices: orphans,
|
|
562
704
|
forkIndices: forks,
|
|
705
|
+
redactedIndices: redacted,
|
|
563
706
|
};
|
|
564
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Acknowledge that records were deliberately redacted (a secret removed from their content).
|
|
710
|
+
*
|
|
711
|
+
* [LOCKED] [REDACTION-IS-A-CHAINED-RECORD] — 2026-08-21
|
|
712
|
+
* [NEVER] let the verifier accept an allow-list that lives outside the chain (a file, an env
|
|
713
|
+
* var, a CLI flag) as grounds to stop calling an altered record altered.
|
|
714
|
+
* WHY: on 2026-08-20 a credentials sweep replaced a password with [REDACTED_SECRET] in 3
|
|
715
|
+
* archived records. Correct, but the chain can only see "content no longer matches its
|
|
716
|
+
* hash", so the compliance report read "tampering" for a deliberate act nobody recorded.
|
|
717
|
+
* An out-of-band allow-list would fix the wording and destroy the property: anyone who
|
|
718
|
+
* can edit the log can edit the list.
|
|
719
|
+
* FIX: the acknowledgement is an `audit.redact` record, appended to the chain like any other,
|
|
720
|
+
* naming the original hash of each redacted record and the hash of its redacted content.
|
|
721
|
+
* It can only be written after the fact, only for records that are actually altered, and
|
|
722
|
+
* a further edit breaks the binding. The verifier reports such records as "redacted",
|
|
723
|
+
* counts them separately, and `ok` ignores them; everything else stays "altered".
|
|
724
|
+
*/
|
|
725
|
+
export function acknowledgeRedaction(indices, reason, actor = "system") {
|
|
726
|
+
if (!reason.trim())
|
|
727
|
+
throw new Error("a reason is required");
|
|
728
|
+
const records = readAuditLog();
|
|
729
|
+
const acknowledged = [];
|
|
730
|
+
const rejected = [];
|
|
731
|
+
const entries = [];
|
|
732
|
+
for (const i of [...new Set(indices)].sort((a, b) => a - b)) {
|
|
733
|
+
const r = records[i];
|
|
734
|
+
if (!r) {
|
|
735
|
+
rejected.push({ index: i, why: "no such record" });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const current = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
|
|
739
|
+
if (current === r.hash) {
|
|
740
|
+
rejected.push({ index: i, why: "content is intact, nothing to acknowledge" });
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (r.event === "audit.redact") {
|
|
744
|
+
rejected.push({ index: i, why: "an acknowledgement cannot itself be redacted" });
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
acknowledged.push(i);
|
|
748
|
+
entries.push({ index: i, hash: r.hash, content_hash: current, ts: r.ts, event: r.event });
|
|
749
|
+
}
|
|
750
|
+
if (entries.length === 0)
|
|
751
|
+
return { acknowledged, rejected, record: null };
|
|
752
|
+
const record = appendAudit("audit.redact", { reason, redacted: entries }, actor);
|
|
753
|
+
return { acknowledged, rejected, record };
|
|
754
|
+
}
|
|
565
755
|
export function filterByRange(records, since, until) {
|
|
566
756
|
return records.filter((r) => {
|
|
567
757
|
if (since && r.ts < since)
|