@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 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
- | Delta bundle version | Daily heartbeat | Detect an out-of-date module cache |
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
 
@@ -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(): {
@@ -1,7 +1,18 @@
1
- // LOCKED — verified March 3 2026 — activation + delta decryption + machine fingerprint + heartbeat
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 & Delta Module System
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 delta bundle (encrypted JS modules)
28
- * 3. Delta is cached locally at ~/.contextengine/delta/
29
- * 4. Premium tools become available
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, dirname } from "path";
43
+ import { join } from "path";
33
44
  import { homedir } from "os";
34
- import { fileURLToPath } from "url";
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
- const DELTA_DIR = join(homedir(), ".contextengine", "delta");
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 below
63
- // in PREMIUM_TOOLS are what consume that data for scoring/audit/cross-project
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 modules
372
- if (existsSync(DELTA_DIR)) {
373
- for (const file of readdirSync(DELTA_DIR)) {
374
- unlinkSync(join(DELTA_DIR, file));
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
- const deltaInstalled = isDeltaInstalled();
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
- return {
411
- ...empty,
412
- refusedReason: `chain does not verify (${integrity.breakReason}) refusing to archive a damaged log`,
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)
@@ -25,6 +25,7 @@ export const KNOWN_COMMANDS = [
25
25
  "audit",
26
26
  "audit-export",
27
27
  "audit-rotate",
28
+ "audit-redact-ack",
28
29
  "audit-verify",
29
30
  "autostart-status",
30
31
  "cost",