@compr/opscontext-mcp 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,89 @@ const HOST = "127.0.0.1";
29
29
  const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
30
30
  const MAX_BODY = 64 * 1024; // 64 KB per batch
31
31
  const MAX_BATCH = 50;
32
+ // [LOCKED] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS] - 2026-09-25
33
+ // [NEVER] widen the accepted kinds back to a prefix, accept a reserved actor, answer a web page's
34
+ // origin, or drop the rate cap.
35
+ // WHY: E2E_REVIEW_2026-09 A2-2 to A2-4, measured on a sandbox receiver: any web page could read
36
+ // /health (Access-Control-Allow-Origin: *, and any Host header answered, the DNS-rebinding
37
+ // shape), so a site could tell OpsContext was installed; a sender holding the secret could
38
+ // write `cli.anything`, a bare `vscode.`, and actor "system"; and one local sender wrote
39
+ // 10,000 valid records in 1.36 s (+12.8 MB) with nothing to slow it, noise that verifies as
40
+ // a valid chain and would bury real events past rotation in seconds.
41
+ // FIX: exactly the capture kinds the hook and the extensions send (CAPTURE_EVENT_KINDS); an actor
42
+ // is a short slug and never "system" or "cli" (the server's and the CLI's own names); CORS
43
+ // headers only for browser-extension origins (the extension posts from its service worker,
44
+ // which needs none); Host must name this machine; a token bucket of BURST records refilled
45
+ // at RATE per second, the excess refused with 429 (the extension keeps and retries a refused
46
+ // batch) and counted in one ingest.rate_limited record per minute.
47
+ export const CAPTURE_EVENT_KINDS = [
48
+ "browser.prompt",
49
+ "browser.response",
50
+ "browser.tool_call",
51
+ "browser.session_start",
52
+ "browser.session_end",
53
+ "browser.capture_miss",
54
+ "vscode.prompt_submit",
55
+ "vscode.tool_call",
56
+ "vscode.session_start",
57
+ ];
58
+ const RESERVED_ACTORS = new Set(["system", "cli"]);
59
+ const ACTOR_RE = /^[a-z][a-z0-9-]{1,31}$/;
60
+ const RATE_PER_SEC = 200;
61
+ const BURST = 1000;
62
+ /** An event kind and actor the door accepts, or why not. Shared with `contextengine emit-event`. */
63
+ export function checkCaptureEvent(kind, actor, opts = {}) {
64
+ if (typeof kind !== "string" || !CAPTURE_EVENT_KINDS.includes(kind)) {
65
+ return `event kind '${String(kind)}' not accepted (one of: ${CAPTURE_EVENT_KINDS.join(", ")})`;
66
+ }
67
+ if (actor === undefined)
68
+ return null;
69
+ if (typeof actor !== "string" || !ACTOR_RE.test(actor))
70
+ return `actor must be a short lowercase name`;
71
+ if (RESERVED_ACTORS.has(actor) && !(opts.allowCliActor && actor === "cli"))
72
+ return `actor '${actor}' is reserved`;
73
+ return null;
74
+ }
75
+ /** Origins that may read responses: browser extensions only, never a web page. */
76
+ function corsHeaders(req) {
77
+ const origin = req.headers.origin;
78
+ if (typeof origin !== "string" || !/^(?:chrome|moz|safari-web)-extension:\/\/[\w.-]+$/.test(origin))
79
+ return {};
80
+ return {
81
+ "Access-Control-Allow-Origin": origin,
82
+ Vary: "Origin",
83
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
84
+ "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
85
+ };
86
+ }
87
+ /** A Host header that names this machine, or none (not a browser). */
88
+ function hostIsLocal(req, port) {
89
+ const host = req.headers.host;
90
+ if (host === undefined)
91
+ return true;
92
+ return [`127.0.0.1:${port}`, `localhost:${port}`].includes(host.toLowerCase());
93
+ }
94
+ const bucket = { tokens: BURST, at: Date.now(), dropped: 0, droppedSince: "" };
95
+ /** Takes `n` tokens if there are enough; refills at RATE_PER_SEC up to BURST. */
96
+ function takeTokens(n) {
97
+ const now = Date.now();
98
+ bucket.tokens = Math.min(BURST, bucket.tokens + ((now - bucket.at) / 1000) * RATE_PER_SEC);
99
+ bucket.at = now;
100
+ if (bucket.tokens < n) {
101
+ if (bucket.dropped === 0)
102
+ bucket.droppedSince = new Date(now).toISOString();
103
+ bucket.dropped += n;
104
+ return false;
105
+ }
106
+ bucket.tokens -= n;
107
+ return true;
108
+ }
109
+ function flushDroppedCount() {
110
+ if (bucket.dropped === 0)
111
+ return;
112
+ safeAppend("ingest.rate_limited", { dropped: bucket.dropped, since: bucket.droppedSince, rate_per_sec: RATE_PER_SEC, burst: BURST });
113
+ bucket.dropped = 0;
114
+ }
32
115
  let serverInstance = null;
33
116
  /** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
34
117
  function loadSecret() {
@@ -66,13 +149,10 @@ function validateEvent(e, idx) {
66
149
  return `events[${idx}]: missing ts`;
67
150
  if (typeof e.payload !== "object" || e.payload === null)
68
151
  return `events[${idx}]: missing payload object`;
69
- // Restrict event kinds to the browser.* + vscode.* + cli.* namespaces.
70
- // The audit module's own writers use other kinds (learning.save etc.);
71
- // those events come from the LOCAL server, not the network surface.
72
- if (!/^(browser|vscode|cli)\./.test(e.event)) {
73
- return `events[${idx}]: event kind '${e.event}' not allowed via HTTP (only browser.*/vscode.*/cli.*)`;
74
- }
75
- return null;
152
+ // Only the capture kinds, never the audit module's own (learning.save, audit.redact, ...), which
153
+ // come from the LOCAL server. [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]
154
+ const err = checkCaptureEvent(e.event, e.actor);
155
+ return err ? `events[${idx}]: ${err}` : null;
76
156
  }
77
157
  /**
78
158
  * The payload as it will be written: every string redacted, and the per-shape counts recorded
@@ -108,24 +188,22 @@ function dropPromptText(payload, event) {
108
188
  text_fingerprint: textFingerprint(text),
109
189
  };
110
190
  }
111
- function sendJson(res, status, body) {
191
+ function sendJson(req, res, status, body, extra = {}) {
112
192
  const json = JSON.stringify(body);
113
193
  res.writeHead(status, {
114
194
  "Content-Type": "application/json",
115
195
  "Content-Length": Buffer.byteLength(json),
116
- // Belt-and-braces: even though the manifest's host_permissions already
117
- // lets the SW POST without preflight, we set the response header so
118
- // future popup-side probe pings work too.
119
- "Access-Control-Allow-Origin": "*",
120
- "Access-Control-Allow-Methods": "POST, OPTIONS",
121
- "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
196
+ // Only a browser extension's origin gets CORS headers; the service worker needs none (its
197
+ // host_permissions cover the endpoint). [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]
198
+ ...corsHeaders(req),
199
+ ...extra,
122
200
  });
123
201
  res.end(json);
124
202
  }
125
203
  function handleEvents(req, res) {
126
204
  const secret = loadSecret();
127
205
  if (!secret) {
128
- sendJson(res, 401, {
206
+ sendJson(req, res, 401, {
129
207
  ok: false,
130
208
  error: "no_secret_configured",
131
209
  hint: "Run: contextengine init-extension-secret",
@@ -134,45 +212,59 @@ function handleEvents(req, res) {
134
212
  }
135
213
  const provided = req.headers["x-opscontext-secret"];
136
214
  if (typeof provided !== "string" || !constantTimeEqual(provided, secret)) {
137
- sendJson(res, 401, { ok: false, error: "bad_secret" });
215
+ sendJson(req, res, 401, { ok: false, error: "bad_secret" });
138
216
  return;
139
217
  }
140
218
  let bytes = 0;
219
+ let tooLarge = false;
141
220
  const chunks = [];
142
221
  req.on("data", (chunk) => {
222
+ if (tooLarge)
223
+ return;
143
224
  bytes += chunk.length;
144
225
  if (bytes > MAX_BODY) {
145
- req.destroy();
146
- sendJson(res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY });
226
+ // Answer first, then drop the connection: destroying before the reply sent the client a
227
+ // bare reset instead of the 413 (E2E_REVIEW_2026-09 A2-5).
228
+ tooLarge = true;
229
+ chunks.length = 0;
230
+ res.on("finish", () => req.destroy());
231
+ sendJson(req, res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY }, { Connection: "close" });
147
232
  return;
148
233
  }
149
234
  chunks.push(chunk);
150
235
  });
151
236
  req.on("end", () => {
237
+ if (tooLarge)
238
+ return;
152
239
  let batch;
153
240
  try {
154
241
  batch = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
155
242
  }
156
243
  catch {
157
- sendJson(res, 400, { ok: false, error: "bad_json" });
244
+ sendJson(req, res, 400, { ok: false, error: "bad_json" });
158
245
  return;
159
246
  }
160
247
  if (!batch || !Array.isArray(batch.events)) {
161
- sendJson(res, 400, { ok: false, error: "missing_events_array" });
248
+ sendJson(req, res, 400, { ok: false, error: "missing_events_array" });
162
249
  return;
163
250
  }
164
251
  if (batch.events.length > MAX_BATCH) {
165
- sendJson(res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
252
+ sendJson(req, res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
166
253
  return;
167
254
  }
168
255
  // Validate every event BEFORE writing any of them.
169
256
  for (let i = 0; i < batch.events.length; i++) {
170
257
  const err = validateEvent(batch.events[i], i);
171
258
  if (err) {
172
- sendJson(res, 400, { ok: false, error: "invalid_event", detail: err });
259
+ sendJson(req, res, 400, { ok: false, error: "invalid_event", detail: err });
173
260
  return;
174
261
  }
175
262
  }
263
+ // [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]: a brake on any one flood, a runaway hook included.
264
+ if (!takeTokens(batch.events.length)) {
265
+ sendJson(req, res, 429, { ok: false, error: "rate_limited", retry_after_ms: 1000 }, { "Retry-After": "1" });
266
+ return;
267
+ }
176
268
  // All valid — write them to audit log via safeAppend.
177
269
  let written = 0;
178
270
  for (const ev of batch.events) {
@@ -182,43 +274,33 @@ function handleEvents(req, res) {
182
274
  safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
183
275
  written++;
184
276
  }
185
- sendJson(res, 200, { ok: true, written });
277
+ sendJson(req, res, 200, { ok: true, written });
186
278
  });
187
279
  }
188
- function handleHealth(_req, res) {
189
- sendJson(res, 200, {
280
+ function handleHealth(req, res) {
281
+ sendJson(req, res, 200, {
190
282
  ok: true,
191
283
  service: "opscontext-event-ingest",
192
284
  port: PORT,
193
285
  secretConfigured: loadSecret() !== null,
194
286
  });
195
287
  }
196
- function handleOptions(_req, res) {
197
- // CORS preflight — the SW shouldn't need this thanks to host_permissions,
198
- // but answering it cleanly costs nothing and helps popup probes.
199
- res.writeHead(204, {
200
- "Access-Control-Allow-Origin": "*",
201
- "Access-Control-Allow-Methods": "POST, OPTIONS",
202
- "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
203
- "Access-Control-Max-Age": "600",
204
- });
288
+ function handleOptions(req, res) {
289
+ // CORS preflight: answered for a browser extension's origin only.
290
+ res.writeHead(204, { ...corsHeaders(req), ...(req.headers.origin ? { "Access-Control-Max-Age": "600" } : {}) });
205
291
  res.end();
206
292
  }
207
- /**
208
- * Boot the local event-ingest HTTP server. Returns the listening port or null
209
- * if the port was already in use (caller may decide whether to retry on
210
- * another port or surface the error).
211
- *
212
- * Safe to call multiple times — second call returns the existing server.
213
- */
214
- export function startEventIngestServer() {
215
- if (serverInstance) {
216
- const addr = serverInstance.address();
217
- return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
218
- }
293
+ let retryTimer = null;
294
+ let handoverTimer = null;
295
+ let rateTimer = null;
296
+ let warnedInUse = false;
297
+ /** One bind attempt. Resolves the port, or null when the port is taken or the bind failed. */
298
+ function listenOnce() {
219
299
  return new Promise((resolve) => {
220
300
  const srv = http.createServer((req, res) => {
221
301
  try {
302
+ if (!hostIsLocal(req, PORT))
303
+ return sendJson(req, res, 403, { ok: false, error: "bad_host" });
222
304
  if (req.method === "OPTIONS")
223
305
  return handleOptions(req, res);
224
306
  const url = req.url || "/";
@@ -226,12 +308,12 @@ export function startEventIngestServer() {
226
308
  return handleEvents(req, res);
227
309
  if (req.method === "GET" && url.startsWith("/health"))
228
310
  return handleHealth(req, res);
229
- sendJson(res, 404, { ok: false, error: "not_found" });
311
+ sendJson(req, res, 404, { ok: false, error: "not_found" });
230
312
  }
231
313
  catch (err) {
232
314
  console.error("[ContextEngine] event-ingest error:", err);
233
315
  try {
234
- sendJson(res, 500, { ok: false, error: "internal" });
316
+ sendJson(req, res, 500, { ok: false, error: "internal" });
235
317
  }
236
318
  catch {
237
319
  /* ignore — response may already be closed */
@@ -240,8 +322,11 @@ export function startEventIngestServer() {
240
322
  });
241
323
  srv.on("error", (err) => {
242
324
  if (err.code === "EADDRINUSE") {
243
- console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest disabled.\n` +
244
- ` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
325
+ if (!warnedInUse) {
326
+ warnedInUse = true;
327
+ console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest waits for it (retrying).\n` +
328
+ ` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
329
+ }
245
330
  resolve(null);
246
331
  return;
247
332
  }
@@ -250,22 +335,116 @@ export function startEventIngestServer() {
250
335
  });
251
336
  srv.listen(PORT, HOST, () => {
252
337
  serverInstance = srv;
338
+ warnedInUse = false;
253
339
  console.error(`[ContextEngine] 🌐 event-ingest on http://${HOST}:${PORT} ` +
254
340
  (loadSecret() ? "(secret loaded)" : "(NO SECRET — run `contextengine init-extension-secret`)"));
255
341
  resolve(PORT);
256
342
  });
257
343
  });
258
344
  }
259
- export function stopEventIngestServer() {
345
+ function closeListener() {
260
346
  return new Promise((resolve) => {
261
347
  if (!serverInstance)
262
348
  return resolve();
263
- serverInstance.close(() => {
264
- serverInstance = null;
265
- resolve();
266
- });
349
+ const srv = serverInstance;
350
+ serverInstance = null;
351
+ srv.close(() => resolve());
352
+ srv.closeAllConnections?.();
267
353
  });
268
354
  }
355
+ /**
356
+ * Boot the local event-ingest HTTP server. Resolves the listening port, or null when the port is
357
+ * taken; in that case it keeps retrying in the background (see below).
358
+ *
359
+ * [LOCKED] [EVENT-PORT-BELONGS-TO-THE-DAEMON] - 2026-09-25
360
+ * [NEVER] give up on the port after one failed bind, or keep it in a chat server while the
361
+ * launchd agent is alive.
362
+ * WHY: [AUTOSTART-IS-THE-STANDING-INDEXER] meant the agent to own the port from boot, but it bound
363
+ * once and gave up. On 2026-09-25 the agent (v2.9.1) held nothing while a chat server on a
364
+ * stale 2.9.0 build held :7842: the redaction guarding the audit log ran whichever build had
365
+ * grabbed the port first, for as long as that chat stayed open, and events were dropped
366
+ * silently once it closed. Nothing recorded who held it (E2E_REVIEW_2026-09 A2-1).
367
+ * FIX: the agent retries every few seconds until it binds; any other server retries only while
368
+ * no agent is alive; a chat server that holds the port hands it over as soon as an agent is
369
+ * registered; the holder is written into its registry record, and `contextengine servers`
370
+ * shows it and warns about a stale or non-agent holder.
371
+ *
372
+ * Safe to call multiple times — a second call returns the existing server.
373
+ */
374
+ export function startEventIngestServer(opts = {}) {
375
+ if (serverInstance) {
376
+ const addr = serverInstance.address();
377
+ return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
378
+ }
379
+ const liveDaemon = opts.liveDaemon ?? (() => null);
380
+ const retryMs = opts.retryMs ?? (opts.daemon ? 5_000 : 15_000);
381
+ const handoverMs = opts.handoverMs ?? 10_000;
382
+ let attempting = false;
383
+ const held = (port) => {
384
+ opts.onPortChange?.(port);
385
+ if (!rateTimer) {
386
+ rateTimer = setInterval(flushDroppedCount, 60_000);
387
+ rateTimer.unref();
388
+ }
389
+ if (!opts.daemon && !handoverTimer) {
390
+ handoverTimer = setInterval(() => {
391
+ const agent = liveDaemon();
392
+ if (agent === null || !serverInstance)
393
+ return;
394
+ if (handoverTimer)
395
+ clearInterval(handoverTimer);
396
+ handoverTimer = null;
397
+ console.error(`[ContextEngine] 🌐 handing the event port :${PORT} to the launchd agent (pid ${agent})`);
398
+ void closeListener().then(() => {
399
+ opts.onPortChange?.(null);
400
+ retry();
401
+ });
402
+ }, handoverMs);
403
+ handoverTimer.unref();
404
+ }
405
+ };
406
+ const retry = () => {
407
+ if (retryTimer)
408
+ return;
409
+ retryTimer = setInterval(() => {
410
+ if (serverInstance || attempting)
411
+ return;
412
+ if (!opts.daemon && liveDaemon() !== null)
413
+ return; // the agent's port: leave it to it
414
+ attempting = true;
415
+ void listenOnce().then((port) => {
416
+ attempting = false;
417
+ if (port === null)
418
+ return;
419
+ if (retryTimer)
420
+ clearInterval(retryTimer);
421
+ retryTimer = null;
422
+ held(port);
423
+ });
424
+ }, retryMs);
425
+ retryTimer.unref();
426
+ };
427
+ // A chat server does not take the port from under a live agent, not even at start.
428
+ if (!opts.daemon && liveDaemon() !== null) {
429
+ retry();
430
+ return Promise.resolve(null);
431
+ }
432
+ return listenOnce().then((port) => {
433
+ if (port === null)
434
+ retry();
435
+ else
436
+ held(port);
437
+ return port;
438
+ });
439
+ }
440
+ export function stopEventIngestServer() {
441
+ for (const t of [retryTimer, handoverTimer, rateTimer])
442
+ if (t)
443
+ clearInterval(t);
444
+ retryTimer = handoverTimer = rateTimer = null;
445
+ flushDroppedCount();
446
+ return closeListener();
447
+ }
269
448
  // Test helpers (not exported in dist surface in production use — but the
270
449
  // module is small enough that tests can import them directly).
271
450
  export const _internal = {
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
5
+ import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findConfigFileWithOrigin } from "./config.js";
6
6
  import { ingestSources } from "./ingest.js";
7
- import { redactSecrets } from "./secret-shapes.js";
7
+ import { redactChunk } from "./secret-shapes.js";
8
8
  import { summarizeSource } from "./source-summary.js";
9
9
  import { searchChunks } from "./search.js";
10
10
  import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
@@ -14,7 +14,9 @@ import { sharedIndexEnabled, corpusId, electIndexer, writeSharedIndex, readShare
14
14
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
15
15
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
16
16
  import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
17
- import { registerServer, listServers, formatServers } from "./server-registry.js";
17
+ import { registerServer, listServers, formatServers, liveDaemonPid } from "./server-registry.js";
18
+ import { secureCeHome } from "./ce-home.js";
19
+ import { QUOTED_TEXT_NOTE } from "./framing.js";
18
20
  import { computeFleetHealth, writeFleetHealth } from "./fleet-health.js";
19
21
  import { startEventIngestServer } from "./http-server.js";
20
22
  import { detect } from "./detector.js";
@@ -38,7 +40,7 @@ try {
38
40
  }
39
41
  catch { /* fallback */ }
40
42
  import { loadAdapters, collectFromAdapters, } from "./adapters.js";
41
- import { gateCheck, activate, getActivationStatus, } from "./activation.js";
43
+ import { gateCheckFresh, licenceCheckState, activate, getActivationStatus, } from "./activation.js";
42
44
  import { ProtocolFirewall } from "./firewall.js";
43
45
  // ---------------------------------------------------------------------------
44
46
  // State
@@ -55,6 +57,8 @@ let role = "indexer";
55
57
  let corpus;
56
58
  let indexerPid = null;
57
59
  let setRegistryRole = null;
60
+ let setRegistryEventPort = null;
61
+ let warnedCwdAdapters = false;
58
62
  /** key -> vector, loaded from ~/.contextengine/embeddings.bin and grown by what we embed. */
59
63
  let vectorStore = new Map();
60
64
  let indexSeq = 0;
@@ -133,6 +137,11 @@ async function buildIndex(opts) {
133
137
  if (autoImport.refused) {
134
138
  console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
135
139
  }
140
+ // [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS]: say what was left out, and how to include it.
141
+ if (autoImport.untrusted.length > 0) {
142
+ console.error(`[ContextEngine] ⏸ Marked learnings in ${autoImport.untrusted.length} project(s) you have not marked as yours were not imported: ` +
143
+ `${autoImport.untrusted.join(", ")}. If they are yours: contextengine trust ${autoImport.untrusted.map((p) => JSON.stringify(p)).join(" ")}`);
144
+ }
136
145
  }
137
146
  // Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
138
147
  const learningChunks = learningsToChunks(activeProjectNames);
@@ -156,9 +165,29 @@ async function buildIndex(opts) {
156
165
  // [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: before the adapters, whose block can return early.
157
166
  chunks = chunks.map(redactChunk);
158
167
  // Collect from plugin adapters
159
- if (config.adapters && config.adapters.length > 0) {
168
+ // [LOCKED] [ADAPTERS-ONLY-FROM-THE-USERS-OWN-CONFIG] - 2026-09-25
169
+ // [NEVER] import an adapter module named by a contextengine.json found in the current folder, or
170
+ // resolve a relative adapter path from the current folder.
171
+ // WHY: without CONTEXTENGINE_CONFIG (the README's setup), the server reads ./contextengine.json
172
+ // from the folder it starts in, which for Claude Code is the project opened. An adapter entry
173
+ // there is import()ed, so a downloaded repository carrying a config and a module ran its own
174
+ // code in the user's session when the project was opened; proven in a sandbox on 2026-09-25
175
+ // (E2E_REVIEW_2026-09 A6-5). A relative path in the user's own config also resolved from
176
+ // that folder, not from the config's.
177
+ // FIX: adapters load only from the config named by CONTEXTENGINE_CONFIG or ~/.contextengine.json,
178
+ // relative paths resolve from that file's folder; a config found in the current folder may
179
+ // still list sources, never code.
180
+ const cfgFile = findConfigFileWithOrigin();
181
+ if (config.adapters && config.adapters.length > 0 && cfgFile?.origin === "cwd") {
182
+ if (!warnedCwdAdapters) {
183
+ warnedCwdAdapters = true;
184
+ console.error(`[ContextEngine] ⛔ Adapters in ${cfgFile.path} were NOT loaded: a config found in the current folder may not run code. ` +
185
+ `Point CONTEXTENGINE_CONFIG at it, or move the adapters to ~/.contextengine.json.`);
186
+ }
187
+ }
188
+ else if (config.adapters && config.adapters.length > 0) {
160
189
  if (opts.loadAdapters) {
161
- const adapterCount = await loadAdapters(config.adapters);
190
+ const adapterCount = await loadAdapters(config.adapters, cfgFile ? dirname(cfgFile.path) : process.cwd());
162
191
  if (adapterCount === 0)
163
192
  return;
164
193
  }
@@ -169,22 +198,8 @@ async function buildIndex(opts) {
169
198
  }
170
199
  }
171
200
  }
172
- /**
173
- * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25
174
- * [NEVER] let a chunk into the index, the shared index file or a search result without passing
175
- * its text through redactSecrets().
176
- * WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
177
- * files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
178
- * runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
179
- * chunks to every AI agent that asks, and the index file rides the weekly backup.
180
- * FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
181
- * adapters), is redacted with the capture shapes (src/secret-shapes.ts) as the index is built.
182
- * The source files are not touched: cleaning those is the owner's call, file by file.
183
- */
184
- function redactChunk(c) {
185
- const r = redactSecrets(c.content);
186
- return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
187
- }
201
+ // [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: redactChunk lives in src/secret-shapes.ts, shared with
202
+ // the CLI's index builder.
188
203
  /** Load the model once; every caller shares the same promise. */
189
204
  function ensureModel() {
190
205
  if (!modelInit)
@@ -577,6 +592,7 @@ server.tool("search_context", "Search across all indexed project knowledge (copi
577
592
  const searchMode = isEmbeddingsReady() ? mode : "keyword (embeddings loading)";
578
593
  const text = [
579
594
  `Search: "${query}" | Mode: ${searchMode} | ${results.length} results`,
595
+ QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
580
596
  "",
581
597
  ...results.map((r, i) => [
582
598
  `--- Result ${i + 1} (${r.label}: ${r.score.toFixed(3)}) ---`,
@@ -603,7 +619,8 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
603
619
  const status = exists
604
620
  ? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
605
621
  : "⚠ file not found";
606
- const summary = exists ? summarizeSource(s) : "";
622
+ // A preview quotes the file: redacted like any chunk. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
623
+ const summary = exists ? redactChunk({ content: summarizeSource(s) }).content : "";
607
624
  return `${s.name}: ${status}${summary ? `\n ${summary}` : ""}\n ${s.path}`;
608
625
  });
609
626
  const embStatus = isEmbeddingsReady()
@@ -612,6 +629,7 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
612
629
  const text = [
613
630
  `ContextEngine v${PKG_VERSION}`,
614
631
  `Sources: ${sources.length} | Chunks: ${chunks.length} | Embeddings: ${embStatus}`,
632
+ QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
615
633
  "",
616
634
  ...lines,
617
635
  ].join("\n");
@@ -648,8 +666,10 @@ server.tool("read_source", "Read the full content of a specific knowledge source
648
666
  isError: true,
649
667
  };
650
668
  }
651
- const content = readFileSync(source.path, "utf-8");
652
- return respond("read_source", `# ${source.name}\n\n${content}`, source_name);
669
+ // [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: the whole file goes through the same redaction as a
670
+ // search result; read_source used to return it raw (E2E_REVIEW_2026-09 A6-6).
671
+ const content = redactChunk({ content: readFileSync(source.path, "utf-8") }).content;
672
+ return respond("read_source", `# ${source.name}\n${QUOTED_TEXT_NOTE}\n\n${content}`, source_name);
653
673
  });
654
674
  // ---------------------------------------------------------------------------
655
675
  // Tool: reindex
@@ -666,7 +686,7 @@ server.tool("reindex", "Force a full re-index of all knowledge sources. Use afte
666
686
  // Tool: list_projects (Multi-Agent Phase 1)
667
687
  // ---------------------------------------------------------------------------
668
688
  server.tool("list_projects", "Discover and analyze all projects in the workspace. Shows tech stack (framework, runtime, key dependencies), infrastructure (git, docker, pm2), and git remote status for each project. Requires Pro license.", {}, async () => {
669
- const gate = gateCheck("list_projects");
689
+ const gate = await gateCheckFresh("list_projects");
670
690
  if (gate)
671
691
  return { content: [{ type: "text", text: gate }] };
672
692
  const projectDirs = loadProjectDirs();
@@ -678,7 +698,7 @@ server.tool("list_projects", "Discover and analyze all projects in the workspace
678
698
  // Tool: check_ports (Multi-Agent Phase 1)
679
699
  // ---------------------------------------------------------------------------
680
700
  server.tool("check_ports", "Scan all projects for port declarations (ecosystem.config.js, docker-compose.yml, .env, package.json) and detect port conflicts. Returns a port allocation map with conflict warnings. Requires Pro license.", {}, async () => {
681
- const gate = gateCheck("check_ports");
701
+ const gate = await gateCheckFresh("check_ports");
682
702
  if (gate)
683
703
  return { content: [{ type: "text", text: gate }] };
684
704
  const projectDirs = loadProjectDirs();
@@ -695,7 +715,7 @@ server.tool("run_audit", "Run the Compliance Agent audit across all projects. Ch
695
715
  .default("all")
696
716
  .describe("Audit scope: all checks, compliance only, version checks only, or port conflicts only"),
697
717
  }, async ({ scope }) => {
698
- const gate = gateCheck("run_audit");
718
+ const gate = await gateCheckFresh("run_audit");
699
719
  if (gate)
700
720
  return { content: [{ type: "text", text: gate }] };
701
721
  const projectDirs = loadProjectDirs();
@@ -712,7 +732,7 @@ server.tool("score_project", "Score one or all projects on AI-readiness (0-100%)
712
732
  .optional()
713
733
  .describe("Project name OR absolute directory path to score. Omit to score all projects."),
714
734
  }, async ({ project }) => {
715
- const gate = gateCheck("score_project");
735
+ const gate = await gateCheckFresh("score_project");
716
736
  if (gate)
717
737
  return { content: [{ type: "text", text: gate }] };
718
738
  // 🔒 LOCKED [SCORE-CANARY-COVERS-EVERY-SCORER] — 2026-08-19
@@ -1169,7 +1189,8 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
1169
1189
  }
1170
1190
  const learnings = listLearnings(category, activeProjectNames);
1171
1191
  const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
1172
- return respond("list_learnings", text);
1192
+ // Redacted like every chunk: a learning can quote a command with its password. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
1193
+ return respond("list_learnings", redactChunk({ content: text }).content);
1173
1194
  });
1174
1195
  // ---------------------------------------------------------------------------
1175
1196
  // Tool: delete_learning (Permanent Learning Store)
@@ -1265,6 +1286,7 @@ server.tool("activation_status", "Check current ContextEngine license status, pl
1265
1286
  `- **Expires**: ${status.expiresAt}`,
1266
1287
  `- **Delta version**: ${status.deltaVersion}`,
1267
1288
  `- **Machine ID**: ${status.machineId}`,
1289
+ `- **Licence check**: ${licenceCheckState()}`, // [LOCK] [LICENSE-IS-CHECKED-DAILY]
1268
1290
  ``,
1269
1291
  ];
1270
1292
  if (status.premiumTools.length > 0) {
@@ -1319,6 +1341,9 @@ function registerResources() {
1319
1341
  // Start
1320
1342
  // ---------------------------------------------------------------------------
1321
1343
  async function main() {
1344
+ // [LOCK] [CE-HOME-IS-PRIVATE]: the folder is 0700 before the registry, the index or the log write
1345
+ // into it.
1346
+ secureCeHome();
1322
1347
  // 0. Inventory this server FIRST, before indexing takes minutes: a server exists the moment it
1323
1348
  // starts. [LOCK] [SERVERS-ARE-INVENTORIED]. With the shared index on, the registry is also
1324
1349
  // the electorate: the record carries the corpus and the role. [LOCK] [ONE-INDEXER-MANY-READERS]
@@ -1331,8 +1356,9 @@ async function main() {
1331
1356
  }
1332
1357
  }
1333
1358
  try {
1334
- const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined });
1359
+ const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
1335
1360
  setRegistryRole = reg.setRole;
1361
+ setRegistryEventPort = reg.setEventPort;
1336
1362
  const fleet = listServers();
1337
1363
  if (corpus) {
1338
1364
  const e = electIndexer(corpus, fleet.servers, process.pid);
@@ -1466,7 +1492,13 @@ async function main() {
1466
1492
  // No-op if secret is missing; the endpoint will refuse with 401 until
1467
1493
  // a secret is configured. Failure to bind (port collision) logs and
1468
1494
  // continues — the MCP server stays usable without browser capture.
1469
- startEventIngestServer().catch((err) => {
1495
+ // [LOCK] [EVENT-PORT-BELONGS-TO-THE-DAEMON]: the launchd agent keeps trying until it holds the
1496
+ // port; a chat server takes it only while no agent is alive, and hands it over when one is.
1497
+ startEventIngestServer({
1498
+ daemon: process.env.OPSCONTEXT_DAEMON === "1",
1499
+ onPortChange: (port) => setRegistryEventPort?.(port),
1500
+ liveDaemon: () => liveDaemonPid(),
1501
+ }).catch((err) => {
1470
1502
  console.error("[ContextEngine] event-ingest start failed:", err);
1471
1503
  });
1472
1504
  }