@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.
@@ -17,7 +17,7 @@
17
17
  import { existsSync, writeFileSync, mkdirSync } from "fs";
18
18
  import { join, dirname } from "path";
19
19
  import { homedir, platform } from "os";
20
- import { execSync } from "child_process";
20
+ import { execFileSync } from "child_process";
21
21
  import { createRequire } from "module";
22
22
  import { fileURLToPath } from "url";
23
23
  // 🔒 LOCKED [M2-ESM-FILENAME-FIX] — 2026-06-24
@@ -52,7 +52,7 @@ function detectNodePath() {
52
52
  function detectOpscontextEntry() {
53
53
  // (1) Try global install via `npm root -g`
54
54
  try {
55
- const globalRoot = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
55
+ const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
56
56
  const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
57
57
  if (existsSync(candidate))
58
58
  return { kind: "global", path: candidate };
@@ -99,6 +99,17 @@ function detectOpscontextEntry() {
99
99
  // FIX: Standard priority; the shared-index flag; CONTEXTENGINE_CONFIG passed through from the
100
100
  // installing shell so its corpus id equals the chats'; the memory skip only if the installer
101
101
  // was itself run with it (an explicit choice, not a default).
102
+ // [LOCKED] [AUTOSTART-ARGV-AND-XML-ESCAPED] - 2026-09-25
103
+ // [NEVER] paste a path into a launchctl/lsof/curl command string, or into the plist unescaped.
104
+ // WHY: with a home folder named "John Smith", `launchctl bootstrap gui/501 ${PLIST_FILE}` handed
105
+ // launchctl the path in two pieces and failed, after `bootout` had already stopped the running
106
+ // agent; a home named "R&D home" produced a plist launchd refuses ("unknown ampersand-escape
107
+ // sequence"); only the passthrough env values were escaped (E2E_REVIEW_2026-09 A3-2, A3-3).
108
+ // FIX: execFileSync with an argument list for every command; xml() on every value in the plist;
109
+ // `plutil -lint` on the written file before anything running is stopped.
110
+ function xml(value) {
111
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
112
+ }
102
113
  export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
103
114
  // OPSCONTEXT_DAEMON tells the server it has no MCP client on stdin and must stay alive on its
104
115
  // own: as a reader it holds no file watchers, and every poller is unref'd, so without this the
@@ -110,7 +121,8 @@ export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
110
121
  passthrough.push(["CONTEXTENGINE_WORKSPACES", env.CONTEXTENGINE_WORKSPACES]);
111
122
  if (env.OPSCONTEXT_SKIP_CLAUDE_MEMORY === "1")
112
123
  passthrough.push(["OPSCONTEXT_SKIP_CLAUDE_MEMORY", "1"]);
113
- const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${v.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string>`).join("\n");
124
+ // [LOCK] [AUTOSTART-ARGV-AND-XML-ESCAPED]: every value, not only the passthrough ones.
125
+ const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)}</string>`).join("\n");
114
126
  return `<?xml version="1.0" encoding="UTF-8"?>
115
127
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
116
128
  <plist version="1.0">
@@ -120,21 +132,21 @@ export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
120
132
 
121
133
  <key>ProgramArguments</key>
122
134
  <array>
123
- <string>${nodePath}</string>
124
- <string>${entryPath}</string>
135
+ <string>${xml(nodePath)}</string>
136
+ <string>${xml(entryPath)}</string>
125
137
  </array>
126
138
 
127
139
  <key>EnvironmentVariables</key>
128
140
  <dict>
129
141
  <key>PATH</key>
130
- <string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
142
+ <string>${xml(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin</string>
131
143
  <key>HOME</key>
132
- <string>${homedir()}</string>
144
+ <string>${xml(homedir())}</string>
133
145
  ${extraEnv}
134
146
  </dict>
135
147
 
136
148
  <key>WorkingDirectory</key>
137
- <string>${homedir()}</string>
149
+ <string>${xml(homedir())}</string>
138
150
 
139
151
  <key>RunAtLoad</key>
140
152
  <true/>
@@ -146,10 +158,10 @@ ${extraEnv}
146
158
  <integer>10</integer>
147
159
 
148
160
  <key>StandardOutPath</key>
149
- <string>${join(LOG_DIR, "mcp-stdout.log")}</string>
161
+ <string>${xml(join(LOG_DIR, "mcp-stdout.log"))}</string>
150
162
 
151
163
  <key>StandardErrorPath</key>
152
- <string>${join(LOG_DIR, "mcp-stderr.log")}</string>
164
+ <string>${xml(join(LOG_DIR, "mcp-stderr.log"))}</string>
153
165
 
154
166
  <key>ProcessType</key>
155
167
  <string>Standard</string>
@@ -165,7 +177,7 @@ function userId() {
165
177
  }
166
178
  function portIsOurs() {
167
179
  try {
168
- execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1`, { stdio: "ignore" });
180
+ execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN"], { stdio: "ignore" });
169
181
  return true;
170
182
  }
171
183
  catch {
@@ -177,7 +189,7 @@ function waitForPort(timeoutSec = 30) {
177
189
  while (Date.now() - start < timeoutSec * 1000) {
178
190
  if (portIsOurs())
179
191
  return true;
180
- execSync("sleep 1");
192
+ execFileSync("sleep", ["1"]);
181
193
  }
182
194
  return false;
183
195
  }
@@ -263,16 +275,25 @@ To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
263
275
  if (portIsOurs()) {
264
276
  console.log(` detected existing process on :${PORT} — relying on launchctl bootout to clean it.`);
265
277
  }
278
+ // [LOCK] [AUTOSTART-ARGV-AND-XML-ESCAPED]: a file launchd would refuse must never cost the user
279
+ // the agent that is running now, so check it before the bootout.
280
+ try {
281
+ execFileSync("plutil", ["-lint", PLIST_FILE], { stdio: ["ignore", "ignore", "pipe"] });
282
+ }
283
+ catch (err) {
284
+ console.error(`❌ The new plist is not valid, nothing was stopped or loaded: ${err instanceof Error ? err.message : String(err)}`);
285
+ process.exit(1);
286
+ }
266
287
  // Idempotent bootstrap: bootout (ignore failure) → bootstrap
267
288
  const uid = userId();
268
289
  try {
269
- execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
290
+ execFileSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" });
270
291
  }
271
292
  catch {
272
293
  /* not loaded — fine */
273
294
  }
274
295
  try {
275
- execSync(`launchctl bootstrap gui/${uid} ${PLIST_FILE}`, { stdio: "inherit" });
296
+ execFileSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_FILE], { stdio: "inherit" });
276
297
  }
277
298
  catch (err) {
278
299
  console.error(`❌ launchctl bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -309,7 +330,7 @@ You can re-install with: opscontext install-autostart`);
309
330
  const uid = userId();
310
331
  let removed = false;
311
332
  try {
312
- execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
333
+ execFileSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" });
313
334
  removed = true;
314
335
  }
315
336
  catch {
@@ -350,7 +371,7 @@ running entrypoint.`);
350
371
  const uid = userId();
351
372
  let launchctlState = "not loaded";
352
373
  try {
353
- const out = execSync(`launchctl print gui/${uid}/${LABEL} 2>/dev/null || true`, { encoding: "utf-8" });
374
+ const out = execFileSync("launchctl", ["print", `gui/${uid}/${LABEL}`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
354
375
  const match = out.match(/state\s*=\s*(\S+)/);
355
376
  if (match)
356
377
  launchctlState = match[1];
@@ -365,7 +386,7 @@ running entrypoint.`);
365
386
  console.log(` port ${PORT}: ${portUp ? "✅ listening" : "❌ not listening"}`);
366
387
  if (portUp) {
367
388
  try {
368
- const health = execSync(`curl -sf http://127.0.0.1:${PORT}/health`, { encoding: "utf-8", timeout: 2000 });
389
+ const health = execFileSync("curl", ["-sf", `http://127.0.0.1:${PORT}/health`], { encoding: "utf-8", timeout: 2000 });
369
390
  console.log(` health: ${health.trim()}`);
370
391
  }
371
392
  catch {
@@ -13,6 +13,7 @@ export interface Settings {
13
13
  }
14
14
  /** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
15
15
  export declare function hookScriptPath(command: string, home?: string): string;
16
+ export declare function shellQuote(value: string): string;
16
17
  /** Removes repeated registrations of our scripts under the same matcher. Keeps the first copy
17
18
  * and every hook that is not ours; drops an entry only when that leaves it empty. */
18
19
  export declare function dropDuplicateHooks(entries: HookEntry[], ourScripts: string[], home?: string): {
@@ -67,16 +67,95 @@ function backupSettings() {
67
67
  // inputs that [OPSCONTEXT-CC-HOOK] protects.
68
68
  // FIX: compare the script path after expanding $HOME, ${HOME} and a leading ~; installing also
69
69
  // removes extra copies of our own commands under the same matcher, and nothing else.
70
+ /** The first shell word of a command, quotes removed ('...', "...", backslash), and the rest. */
71
+ function splitFirstWord(command) {
72
+ const s = command ?? "";
73
+ let i = 0;
74
+ while (i < s.length && /\s/.test(s[i]))
75
+ i++;
76
+ let word = "";
77
+ while (i < s.length && !/\s/.test(s[i])) {
78
+ const ch = s[i];
79
+ if (ch === "'") {
80
+ const j = s.indexOf("'", i + 1);
81
+ word += j < 0 ? s.slice(i + 1) : s.slice(i + 1, j);
82
+ i = j < 0 ? s.length : j + 1;
83
+ }
84
+ else if (ch === '"') {
85
+ let j = i + 1;
86
+ while (j < s.length && s[j] !== '"') {
87
+ if (s[j] === "\\" && j + 1 < s.length) {
88
+ word += s[j + 1];
89
+ j += 2;
90
+ }
91
+ else {
92
+ word += s[j];
93
+ j++;
94
+ }
95
+ }
96
+ i = j + 1;
97
+ }
98
+ else if (ch === "\\" && i + 1 < s.length) {
99
+ word += s[i + 1];
100
+ i += 2;
101
+ }
102
+ else {
103
+ word += ch;
104
+ i++;
105
+ }
106
+ }
107
+ return { word, rest: s.slice(i).trim() };
108
+ }
70
109
  /** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
71
110
  export function hookScriptPath(command, home = homedir()) {
72
- const m = /^\s*(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(command ?? "");
73
- const path = m ? (m[1] ?? m[2] ?? m[3]) : "";
74
- return path.replace(/^(?:\$HOME|\$\{HOME\}|~)(?=\/)/, home);
111
+ return splitFirstWord(command).word.replace(/^(?:\$HOME|\$\{HOME\}|~)(?=\/)/, home);
75
112
  }
76
113
  /** The command with its script path expanded, so two spellings of one call compare equal. */
77
114
  function normalizedCommand(command, home) {
78
- const args = (command ?? "").trim().replace(/^(?:"[^"]*"|'[^']*'|\S+)/, "").trim();
79
- return `${hookScriptPath(command, home)} ${args}`.trim();
115
+ return `${hookScriptPath(command, home)} ${splitFirstWord(command).rest}`.trim();
116
+ }
117
+ // [LOCKED] [HOOK-PATHS-ARE-SHELL-QUOTED] - 2026-09-25
118
+ // [NEVER] write a script path into a hook command, or into the generated gate script, without
119
+ // shellQuote(): Claude Code runs every hook command through a shell.
120
+ // WHY: with a home folder named "John Smith" the installer wrote `/Users/John Smith/.claude/...`
121
+ // unquoted: every hook exited 127, the install's own count found 0 of ours and failed, and
122
+ // each re-run added four more broken entries (4, then 8). A double quote in the path made the
123
+ // shell exit 2, which for a Stop hook means "block the turn" (E2E_REVIEW_2026-09 A3-2, A3-4).
124
+ // FIX: shellQuote() leaves a plain path as it is (existing installs compare equal and do not
125
+ // churn) and single-quotes anything else; splitFirstWord() reads commands the way the shell
126
+ // does, so a quoted path compares equal to its plain spelling; an unquoted copy of one of our
127
+ // paths that contains a space is recognised as ours and repaired.
128
+ export function shellQuote(value) {
129
+ return /^[A-Za-z0-9_./@%+=:,-]+$/.test(value) ? value : scriptQuote(value);
130
+ }
131
+ /** Always single-quoted: for the lines of a generated sh script. */
132
+ function scriptQuote(value) {
133
+ return `'${value.replace(/'/g, "'\\''")}'`;
134
+ }
135
+ /** An old install's unquoted command for one of our scripts whose path contains whitespace. */
136
+ function isBrokenUnquotedCopy(command, script) {
137
+ if (!/\s/.test(script))
138
+ return false;
139
+ const c = (command ?? "").trim();
140
+ return c === script || c.startsWith(`${script} `);
141
+ }
142
+ /** True when a hook command runs one of `scripts`, however it is spelled. */
143
+ function runsOneOf(command, scripts, home = homedir()) {
144
+ return scripts.includes(hookScriptPath(command, home)) || scripts.some((s) => isBrokenUnquotedCopy(command, s));
145
+ }
146
+ /** Rewrites unquoted, broken copies of our commands to their quoted form (then dedup sees them). */
147
+ function repairUnquotedCommands(entries, scripts) {
148
+ let repaired = 0;
149
+ for (const e of entries) {
150
+ for (const h of e.hooks ?? []) {
151
+ const s = scripts.find((x) => isBrokenUnquotedCopy(h.command, x));
152
+ if (!s)
153
+ continue;
154
+ h.command = `${shellQuote(s)} ${h.command.trim().slice(s.length).trim()}`.trim();
155
+ repaired++;
156
+ }
157
+ }
158
+ return repaired;
80
159
  }
81
160
  function hookAlreadyWired(entries, hookScript, home = homedir()) {
82
161
  return (entries ?? []).some((e) => e.hooks?.some((h) => hookScriptPath(h.command, home) === hookScript));
@@ -211,6 +290,10 @@ Run: opscontext install-autostart
211
290
  return;
212
291
  }
213
292
  const simplicityAsked = args.includes("--simplicity");
293
+ // Parse settings.json before writing anything: a malformed file is refused with nothing changed
294
+ // (it used to be refused after the emit script had already been copied).
295
+ const original = existsSync(SETTINGS_FILE) ? readFileSync(SETTINGS_FILE, "utf-8") : null;
296
+ const settings = readSettings();
214
297
  // Step 1: Install / verify the hook script
215
298
  mkdirSync(HOOKS_DIR, { recursive: true });
216
299
  const src = bundledFile("claude-code-hook.sh");
@@ -223,19 +306,18 @@ Run: opscontext install-autostart
223
306
  copyFileSync(src, HOOK_SCRIPT);
224
307
  chmodSync(HOOK_SCRIPT, 0o755);
225
308
  console.log(`✅ Installed hook script: ${HOOK_SCRIPT}`);
226
- // Step 2: Splice into settings.json
227
- const settings = readSettings();
228
- const backup = backupSettings();
229
- if (backup)
230
- console.log(`✅ Backed up settings.json → ${backup}`);
309
+ // Step 2: Splice into settings.json (written, after a backup, only if something changes)
231
310
  settings.hooks ??= {};
232
311
  const hookCmdPrefix = `${HOOK_SCRIPT}`; // compared by expanded path, [HOOKS-COMPARED-BY-EXPANDED-PATH]
233
312
  // [LOCK] [HOOKS-COMPARED-BY-EXPANDED-PATH]: remove extra copies before deciding what to add.
313
+ // [LOCK] [HOOK-PATHS-ARE-SHELL-QUOTED]: first repair unquoted copies an older install wrote for a
314
+ // path with a space, so the dedup below sees them as ours.
234
315
  let deduped = 0;
235
316
  for (const kind of [...EVENT_KINDS, "Stop"]) {
236
317
  const entries = settings.hooks[kind];
237
318
  if (!entries)
238
319
  continue;
320
+ repairUnquotedCommands(entries, OUR_SCRIPTS);
239
321
  const r = dropDuplicateHooks(entries, OUR_SCRIPTS);
240
322
  settings.hooks[kind] = r.entries;
241
323
  deduped += r.removed;
@@ -252,7 +334,7 @@ Run: opscontext install-autostart
252
334
  hooks: [
253
335
  {
254
336
  type: "command",
255
- command: `${HOOK_SCRIPT} ${kind}`,
337
+ command: `${shellQuote(HOOK_SCRIPT)} ${kind}`,
256
338
  timeout: 5,
257
339
  },
258
340
  ],
@@ -267,14 +349,14 @@ Run: opscontext install-autostart
267
349
  // Step 3: the Stop gate. Absolute node + CLI paths: hooks run without the user's shell PATH.
268
350
  // Prefer the global install: an npx cache copy can be pruned and the hook would then exit 127.
269
351
  const cliPath = globalCliPath() ?? join(__dirname_esm, "cli.js");
270
- writeFileSync(GATE_SCRIPT, `#!/bin/sh\n# Generated by \`opscontext install-claude-hook\`: the CE session gate on Claude Code Stop.\n# Exit 2 = the turn may not end yet (reason on stderr). See: contextengine session-gate --help\nexec "${process.execPath}" "${cliPath}" session-gate\n`);
352
+ writeFileSync(GATE_SCRIPT, `#!/bin/sh\n# Generated by \`opscontext install-claude-hook\`: the CE session gate on Claude Code Stop.\n# Exit 2 = the turn may not end yet (reason on stderr). See: contextengine session-gate --help\nexec ${scriptQuote(process.execPath)} ${scriptQuote(cliPath)} session-gate\n`);
271
353
  chmodSync(GATE_SCRIPT, 0o755);
272
354
  settings.hooks.Stop ??= [];
273
355
  if (hookAlreadyWired(settings.hooks.Stop, GATE_SCRIPT)) {
274
356
  skipped++;
275
357
  }
276
358
  else {
277
- settings.hooks.Stop.push({ hooks: [{ type: "command", command: GATE_SCRIPT, timeout: 15 }] });
359
+ settings.hooks.Stop.push({ hooks: [{ type: "command", command: shellQuote(GATE_SCRIPT), timeout: 15 }] });
278
360
  added++;
279
361
  }
280
362
  console.log(`✅ Installed session gate: ${GATE_SCRIPT}`);
@@ -296,7 +378,7 @@ Run: opscontext install-autostart
296
378
  else {
297
379
  settings.hooks.PostToolUse.push({
298
380
  matcher: SIMPLICITY_MATCHER,
299
- hooks: [{ type: "command", command: SIMPLICITY_SCRIPT, timeout: 30 }],
381
+ hooks: [{ type: "command", command: shellQuote(SIMPLICITY_SCRIPT), timeout: 30 }],
300
382
  });
301
383
  added++;
302
384
  }
@@ -307,7 +389,16 @@ Run: opscontext install-autostart
307
389
  else
308
390
  console.log(`⚠️ ruff not found (PATH, /opt/homebrew/bin, /usr/local/bin, ~/.local/bin, ~/.cargo/bin): the gate stays silent until it is installed (brew install ruff, or pipx install ruff).`);
309
391
  }
310
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
392
+ // A re-run that changes nothing writes nothing and leaves no backup behind (a copy of
393
+ // settings.json per run piled up, env values and all).
394
+ let backup = "";
395
+ const unchanged = original !== null && JSON.stringify(JSON.parse(original)) === JSON.stringify(settings);
396
+ if (!unchanged) {
397
+ backup = backupSettings();
398
+ if (backup)
399
+ console.log(`✅ Backed up settings.json → ${backup}`);
400
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
401
+ }
311
402
  const removedNote = deduped ? `, ${deduped} duplicate registrations removed` : "";
312
403
  console.log(`✅ ${added} hook entries added, ${skipped} already present${removedNote}.`);
313
404
  // [LOCKED] [INSTALL-VERIFIES-BY-COUNT] - 2026-09-15
@@ -348,33 +439,50 @@ The hook script files under ~/.claude/hooks/ are left in place — delete
348
439
  manually if you want them gone. The audit log is NOT touched.`);
349
440
  return;
350
441
  }
351
- const ourNames = args.includes("--simplicity")
352
- ? ["opscontext-simplicity-gate.py"]
353
- : ["opscontext-emit.sh", "opscontext-session-gate.sh", "opscontext-simplicity-gate.py"];
442
+ // [LOCKED] [UNINSTALL-REMOVES-ONLY-OUR-COMMANDS] - 2026-09-25
443
+ // [NEVER] drop a whole hook entry because one of its commands is ours, or recognise ours by a
444
+ // substring of the command text.
445
+ // WHY: the uninstaller removed every entry whose command text contained one of our file names.
446
+ // In a sandbox it deleted a user's company-audit.sh that shared an entry with our emit hook,
447
+ // and a user's notify-opscontext-emit.sh.done that merely contained our name
448
+ // (E2E_REVIEW_2026-09 A3-1). [CLAUDE-HOOK-INSTALL] asks for the "preserve existing"
449
+ // discipline in every code path; this one broke it.
450
+ // FIX: remove only the commands that run our scripts, compared by expanded path like the
451
+ // installer ([HOOKS-COMPARED-BY-EXPANDED-PATH]); drop an entry only when that leaves it
452
+ // empty; back up and write only when something was removed.
453
+ const ours = args.includes("--simplicity") ? [SIMPLICITY_SCRIPT] : OUR_SCRIPTS;
354
454
  const settings = readSettings();
355
455
  if (!settings.hooks) {
356
456
  console.log(` (no hooks block in settings.json — nothing to remove)`);
357
457
  return;
358
458
  }
359
- const backup = backupSettings();
360
- if (backup)
361
- console.log(`✅ Backed up settings.json → ${backup}`);
362
459
  let removed = 0;
363
460
  for (const kind of [...EVENT_KINDS, "Stop"]) {
364
461
  const entries = settings.hooks[kind];
365
462
  if (!entries)
366
463
  continue;
367
- const filtered = entries.filter((e) => !e.hooks?.some((h) => ourNames.some((n) => h.command?.includes(n))));
368
- removed += entries.length - filtered.length;
369
- if (filtered.length === 0) {
464
+ const kept = [];
465
+ for (const e of entries) {
466
+ const before = e.hooks ?? [];
467
+ const hooks = before.filter((h) => !runsOneOf(h.command, ours));
468
+ removed += before.length - hooks.length;
469
+ if (hooks.length > 0 || before.length === 0)
470
+ kept.push({ ...e, hooks });
471
+ }
472
+ if (kept.length === 0) {
370
473
  delete settings.hooks[kind];
371
474
  }
372
475
  else {
373
- settings.hooks[kind] = filtered;
476
+ settings.hooks[kind] = kept;
374
477
  }
375
478
  }
376
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
377
- console.log(`✅ Removed ${removed} hook entries.`);
479
+ if (removed > 0) {
480
+ const backup = backupSettings();
481
+ if (backup)
482
+ console.log(`✅ Backed up settings.json → ${backup}`);
483
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
484
+ }
485
+ console.log(`✅ Removed ${removed} OpsContext hook command(s); every other hook kept.`);
378
486
  console.log(` Hook script kept at: ${HOOK_SCRIPT}`);
379
487
  console.log(` Audit log untouched.`);
380
488
  }
@@ -122,6 +122,7 @@ export declare function autoImportFromSources(sources: Array<{
122
122
  updated: number;
123
123
  ignored: number;
124
124
  refused?: string;
125
+ untrusted: string[];
125
126
  };
126
127
  /**
127
128
  * Get the store stats.
package/dist/learnings.js CHANGED
@@ -5,6 +5,7 @@ import { join, dirname } from "path";
5
5
  import { homedir } from "os";
6
6
  import { fileURLToPath } from "url";
7
7
  import { safeAppend } from "./audit.js";
8
+ import { trustedProjects, looksLikeMarkedLearnings } from "./trusted-projects.js";
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = dirname(__filename);
10
11
  /**
@@ -1013,6 +1014,10 @@ export function autoImportFromSources(sources) {
1013
1014
  let totalIgnored = 0;
1014
1015
  let processed = 0;
1015
1016
  let refused;
1017
+ // [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS] (src/trusted-projects.ts): seeded, the first
1018
+ // time, with every project that already has learnings in the store.
1019
+ const trusted = trustedProjects(() => [...new Set(loadStore().learnings.map((l) => l.project).filter((p) => !!p))]);
1020
+ const untrusted = new Set();
1016
1021
  // One load and one save for the whole sweep (~880 files), instead of one full-file
1017
1022
  // rewrite per rule per file. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
1018
1023
  try {
@@ -1025,6 +1030,18 @@ export function autoImportFromSources(sources) {
1025
1030
  continue;
1026
1031
  // Extract project name from source name (e.g., "ContextEngine — copilot-instructions.md")
1027
1032
  const project = source.name.split(" — ")[0]?.trim() || undefined;
1033
+ // A project the owner has not marked as theirs is searchable, never imported automatically.
1034
+ // [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS]
1035
+ if (project && !trusted.has(project.toLowerCase())) {
1036
+ try {
1037
+ if (looksLikeMarkedLearnings(readFileSync(source.path, "utf-8")))
1038
+ untrusted.add(project);
1039
+ }
1040
+ catch {
1041
+ /* unreadable: nothing to import anyway */
1042
+ }
1043
+ continue;
1044
+ }
1028
1045
  // Strict by construction: only marked learnings. [LOCK] [AUTO-IMPORT-ONLY-MARKED-LEARNINGS]
1029
1046
  const result = importLearningsFromFile(source.path, "other", project);
1030
1047
  totalImported += result.imported;
@@ -1043,7 +1060,7 @@ export function autoImportFromSources(sources) {
1043
1060
  totalUpdated = 0;
1044
1061
  processed = 0;
1045
1062
  }
1046
- return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored, refused };
1063
+ return { total: processed, imported: totalImported, updated: totalUpdated, ignored: totalIgnored, refused, untrusted: [...untrusted] };
1047
1064
  }
1048
1065
  /**
1049
1066
  * Get the store stats.
@@ -1,3 +1,10 @@
1
+ /** Tests only: verify against another key, in this process. Pass null to restore the pinned key. */
2
+ export declare function __setLicensePublicKeyForTesting(pem: string | null): void;
3
+ /**
4
+ * True when `signatureB64` is an Ed25519 signature of `bytes` by the pinned key. Used for the
5
+ * community rules file, which is signed as a whole. [LOCK] [COMMUNITY-TIER-A-IS-SIGNED]
6
+ */
7
+ export declare function verifyDetachedSignature(bytes: Buffer, signatureB64: string): boolean;
1
8
  export declare const LICENSE_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAnWMq7ITUPmC/8yx9XmpYktaWmQtXDOx6R2nqSdibq+Y=\n-----END PUBLIC KEY-----";
2
9
  export declare const LICENSE_PUBKEY_FINGERPRINT = "12d0c34c917a47fbed99945d2b7fb439";
3
10
  export interface SignableLicensePayload {
@@ -38,8 +45,8 @@ export type VerifyResult = {
38
45
  * - ok=false, reason=<string> → signature missing / invalid /
39
46
  * tampered / wrong keypair
40
47
  *
41
- * Override the public key via CE_LICENSE_PUBLIC_KEY env var (PEM contents)
42
- * for self-hosters running their own activation server.
48
+ * The key is the pinned one (tests: __setLicensePublicKeyForTesting). No environment variable
49
+ * replaces it any more. [LOCK] [LICENSE-SIG]
43
50
  */
44
51
  export declare function verifyLicenseSignature(license: SignableLicensePayload & {
45
52
  signature: string;
@@ -6,8 +6,13 @@
6
6
  // the change.
7
7
  // ⛔ NEVER ship the public key as a mutable variable. It's a constant
8
8
  // that pins the client to the production activation server.
9
- // Self-hosters override via CE_LICENSE_PUBLIC_KEY env var, which
10
- // is the documented escape hatch.
9
+ // [NEVER] bring back an environment variable that replaces it (2026-09-25).
10
+ // Self-hosters build from source with their own key in LICENSE_PUBLIC_KEY_PEM.
11
+ // WHY (2026-09-25, owner's decision after E2E_REVIEW_2026-09 A4-1): the old escape hatch,
12
+ // CE_LICENSE_PUBLIC_KEY, documented in the shipped CHANGELOG, let anyone generate a key pair,
13
+ // sign their own licence and unlock every Pro tool with one variable; proven in a sandbox
14
+ // (`score` ran on a self-signed licence). Tests swap the key in-process only, through
15
+ // __setLicensePublicKeyForTesting().
11
16
  // ⛔ Legacy SHA-256 signatures are NOW REJECTED (flag day reached
12
17
  // 2026-06-11 — earlier than the originally scheduled 2026-08-15
13
18
  // because the customer base is effectively empty and no one would
@@ -26,9 +31,32 @@
26
31
  // Ed25519 license signature — verify side (client).
27
32
  //
28
33
  // Pairs with server/src/license-sig.ts (sign side). Public key below is
29
- // pinned to the production activation server (api.compr.ch). Self-hosters
30
- // override with CE_LICENSE_PUBLIC_KEY env var.
34
+ // pinned to the production activation server (api.compr.ch). No runtime
35
+ // override: see [LICENSE-SIG] above.
31
36
  import { createPublicKey, verify } from "crypto";
37
+ let testPublicKeyPem = null;
38
+ /** Tests only: verify against another key, in this process. Pass null to restore the pinned key. */
39
+ export function __setLicensePublicKeyForTesting(pem) {
40
+ testPublicKeyPem = pem;
41
+ }
42
+ function activePublicKeyPem() {
43
+ return testPublicKeyPem ?? LICENSE_PUBLIC_KEY_PEM;
44
+ }
45
+ /**
46
+ * True when `signatureB64` is an Ed25519 signature of `bytes` by the pinned key. Used for the
47
+ * community rules file, which is signed as a whole. [LOCK] [COMMUNITY-TIER-A-IS-SIGNED]
48
+ */
49
+ export function verifyDetachedSignature(bytes, signatureB64) {
50
+ try {
51
+ const sig = Buffer.from(signatureB64.trim(), "base64");
52
+ if (sig.length !== 64)
53
+ return false;
54
+ return verify(null, bytes, createPublicKey(activePublicKeyPem()), sig);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
32
60
  // Production Ed25519 public key. Paired private key lives ONLY on the
33
61
  // activation server. Public key SHA-256 fingerprint (first 32 hex chars):
34
62
  // 12d0c34c917a47fbed99945d2b7fb439
@@ -66,10 +94,10 @@ export function canonicalPayload(license) {
66
94
  * - ok=false, reason=<string> → signature missing / invalid /
67
95
  * tampered / wrong keypair
68
96
  *
69
- * Override the public key via CE_LICENSE_PUBLIC_KEY env var (PEM contents)
70
- * for self-hosters running their own activation server.
97
+ * The key is the pinned one (tests: __setLicensePublicKeyForTesting). No environment variable
98
+ * replaces it any more. [LOCK] [LICENSE-SIG]
71
99
  */
72
- export function verifyLicenseSignature(license, publicKeyPem = process.env.CE_LICENSE_PUBLIC_KEY || LICENSE_PUBLIC_KEY_PEM) {
100
+ export function verifyLicenseSignature(license, publicKeyPem = activePublicKeyPem()) {
73
101
  if (!license.signature || license.signature.length === 0) {
74
102
  return { ok: false, reason: "signature field missing" };
75
103
  }
@@ -19,6 +19,25 @@ export interface RedactionResult {
19
19
  }
20
20
  /** Replace every credential shape in `input`. Returns the new text and the count per shape. */
21
21
  export declare function redactSecrets(input: string): RedactionResult;
22
+ /**
23
+ * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25 (moved here from src/index.ts the same day)
24
+ * [NEVER] let a chunk into the index, the shared index file or a search result without passing
25
+ * its text through redactSecrets(), in the MCP server AND in the CLI.
26
+ * WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
27
+ * files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
28
+ * runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
29
+ * chunks to every AI agent that asks, and the index file rides the weekly backup.
30
+ * The same day the fix was found to cover one builder of two: the CLI's initEngine() never
31
+ * called it, and `contextengine search` returned 7 of 7 planted fake credentials in clear
32
+ * (E2E_REVIEW_2026-09 A6-2). The VS Code extension shells out to that CLI.
33
+ * FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
34
+ * adapters), is redacted with the capture shapes as the index is built, by this one function,
35
+ * called by both builders (src/index.ts buildIndex, src/cli.ts initEngine).
36
+ * The source files are not touched: cleaning those is the owner's call, file by file.
37
+ */
38
+ export declare function redactChunk<T extends {
39
+ content: string;
40
+ }>(c: T): T;
22
41
  /** Redact every string inside a JSON-like value, returning a copy and the merged counts. */
23
42
  export declare function redactPayload<T>(value: T): {
24
43
  value: T;
@@ -36,7 +36,9 @@ export const SECRET_SHAPES = [
36
36
  { id: "telegram_bot_token", re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/g },
37
37
  { id: "jwt", re: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g },
38
38
  // scheme://user:SECRET@host, the shape of every database URL found on 2026-09-24.
39
- { id: "url_password", re: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@'"`]+:)[^\s@/'"`]+(?=@)/gi, keepPrefix: true },
39
+ // The user part may be empty: redis://:secret@host is how Redis spells a password-only URL
40
+ // (E2E_REVIEW_2026-09 A6-3: it came back raw from search and from the receiver).
41
+ { id: "url_password", re: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@'"`]*:)[^\s@/'"`]+(?=@)/gi, keepPrefix: true },
40
42
  { id: "sshpass_password", re: new RegExp(String.raw `(\bsshpass\s+-p\s*)${QUOTED_OR_BARE}`, "g"), keepPrefix: true },
41
43
  { id: "sshpass_env", re: new RegExp(String.raw `(\bSSHPASS=)${QUOTED_OR_BARE}`, "g"), keepPrefix: true },
42
44
  // mysql -pSECRET (the value is glued to the flag; a bare -p prompts and is left alone).
@@ -48,10 +50,13 @@ export const SECRET_SHAPES = [
48
50
  { id: "api_key_header", re: /(\bx-api-key:\s*)[^\s'"]{8,}/gi, keepPrefix: true },
49
51
  // name = value, the long tail. Skips variables, env lookups, paths, placeholders already
50
52
  // redacted, type names and function calls.
53
+ // A name that is PASS alone or ends in a separator plus PASS (SMTP_PASS, DB_PASS, db.pass) counts
54
+ // too: a real mail password sat in the shared index under SMTP_PASS (E2E_REVIEW_2026-09 A6-3).
55
+ // The separator keeps bypass and compass out.
51
56
  {
52
57
  id: "credential_assignment",
53
58
  // A backtick opens a value too: "password: `...`" in Markdown, found in the real log.
54
- re: /(\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
59
+ re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|(?:[\w.-]*[_.-])?pass)["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
55
60
  keepPrefix: true,
56
61
  skip: (value, after) => after.startsWith("(") ||
57
62
  (/^[A-Za-z_$][\w$.]*\(/.test(value) && after.startsWith(")")) || // getToken(user), not a value
@@ -106,6 +111,26 @@ export function redactSecrets(input) {
106
111
  }
107
112
  return { text, counts };
108
113
  }
114
+ /**
115
+ * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25 (moved here from src/index.ts the same day)
116
+ * [NEVER] let a chunk into the index, the shared index file or a search result without passing
117
+ * its text through redactSecrets(), in the MCP server AND in the CLI.
118
+ * WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
119
+ * files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
120
+ * runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
121
+ * chunks to every AI agent that asks, and the index file rides the weekly backup.
122
+ * The same day the fix was found to cover one builder of two: the CLI's initEngine() never
123
+ * called it, and `contextengine search` returned 7 of 7 planted fake credentials in clear
124
+ * (E2E_REVIEW_2026-09 A6-2). The VS Code extension shells out to that CLI.
125
+ * FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
126
+ * adapters), is redacted with the capture shapes as the index is built, by this one function,
127
+ * called by both builders (src/index.ts buildIndex, src/cli.ts initEngine).
128
+ * The source files are not touched: cleaning those is the owner's call, file by file.
129
+ */
130
+ export function redactChunk(c) {
131
+ const r = redactSecrets(c.content);
132
+ return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
133
+ }
109
134
  /** Redact every string inside a JSON-like value, returning a copy and the merged counts. */
110
135
  export function redactPayload(value) {
111
136
  const counts = {};