@compr/opscontext-mcp 2.9.1 → 2.11.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.
@@ -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,16 @@ 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 ends in a separator plus PASS (SMTP_PASS, DB_PASS, db.pass) counts too: a real mail
54
+ // password sat in the shared index under SMTP_PASS (E2E_REVIEW_2026-09 A6-3). The separator keeps
55
+ // bypass and compass out. A bare `pass` counts only before an equals sign, or before a quoted value
56
+ // after a colon (nodemailer's auth block): as first shipped in 2.10.0 it also took prose (a README's
57
+ // "PII pass" list) and code (a count of passing checks in agents.ts), which the public-release scan
58
+ // refused.
51
59
  {
52
60
  id: "credential_assignment",
53
61
  // 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,
62
+ re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|[\w.-]*[_.-]pass|pass(?=["'`]?\s*=|["'`]?\s*:\s*["'`]))["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
55
63
  keepPrefix: true,
56
64
  skip: (value, after) => after.startsWith("(") ||
57
65
  (/^[A-Za-z_$][\w$.]*\(/.test(value) && after.startsWith(")")) || // getToken(user), not a value
@@ -106,6 +114,26 @@ export function redactSecrets(input) {
106
114
  }
107
115
  return { text, counts };
108
116
  }
117
+ /**
118
+ * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25 (moved here from src/index.ts the same day)
119
+ * [NEVER] let a chunk into the index, the shared index file or a search result without passing
120
+ * its text through redactSecrets(), in the MCP server AND in the CLI.
121
+ * WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
122
+ * files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
123
+ * runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
124
+ * chunks to every AI agent that asks, and the index file rides the weekly backup.
125
+ * The same day the fix was found to cover one builder of two: the CLI's initEngine() never
126
+ * called it, and `contextengine search` returned 7 of 7 planted fake credentials in clear
127
+ * (E2E_REVIEW_2026-09 A6-2). The VS Code extension shells out to that CLI.
128
+ * FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
129
+ * adapters), is redacted with the capture shapes as the index is built, by this one function,
130
+ * called by both builders (src/index.ts buildIndex, src/cli.ts initEngine).
131
+ * The source files are not touched: cleaning those is the owner's call, file by file.
132
+ */
133
+ export function redactChunk(c) {
134
+ const r = redactSecrets(c.content);
135
+ return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
136
+ }
109
137
  /** Redact every string inside a JSON-like value, returning a copy and the merged counts. */
110
138
  export function redactPayload(value) {
111
139
  const counts = {};
@@ -13,6 +13,10 @@ export interface ServerRecord {
13
13
  * the one writing the shared index for it, or a reader of it. Absent on older builds. */
14
14
  corpus?: string;
15
15
  role?: "indexer" | "reader";
16
+ /** Since 2.10.0: started as the launchd agent (OPSCONTEXT_DAEMON=1). [LOCK] [EVENT-PORT-BELONGS-TO-THE-DAEMON] */
17
+ daemon?: boolean;
18
+ /** Since 2.10.0: the event-ingest port this server holds right now; absent when it holds none. */
19
+ eventPort?: number;
16
20
  }
17
21
  export interface ServerReport {
18
22
  servers: Array<ServerRecord & {
@@ -46,11 +50,15 @@ export declare function registerServer(opts: {
46
50
  script: string;
47
51
  corpus?: string;
48
52
  role?: "indexer" | "reader";
53
+ daemon?: boolean;
49
54
  }): {
50
55
  record: ServerRecord;
51
56
  stop: () => void;
52
57
  setRole: (role: "indexer" | "reader") => void;
58
+ setEventPort: (port: number | null) => void;
53
59
  };
60
+ /** The pid of a live launchd agent other than `exceptPid`, or null. Cheap: no build hashing. */
61
+ export declare function liveDaemonPid(exceptPid?: number): number | null;
54
62
  /** Read every record, drop the dead ones, compare builds with the files on disk now. */
55
63
  export declare function listServers(): ServerReport;
56
64
  /** CPU seconds consumed and resident memory of a live pid, from ps (hardcoded argv, no shell). */
@@ -96,6 +96,7 @@ export function registerServer(opts) {
96
96
  node: process.version,
97
97
  ...(opts.corpus ? { corpus: opts.corpus } : {}),
98
98
  ...(opts.role ? { role: opts.role } : {}),
99
+ ...(opts.daemon ? { daemon: true } : {}),
99
100
  };
100
101
  const file = join(dir, `${process.pid}.json`);
101
102
  const write = () => { try {
@@ -121,7 +122,33 @@ export function registerServer(opts) {
121
122
  process.on(sig, () => { stop(); process.exit(0); });
122
123
  }
123
124
  const setRole = (role) => { record.role = role; write(); };
124
- return { record, stop, setRole };
125
+ const setEventPort = (port) => {
126
+ if (port === null)
127
+ delete record.eventPort;
128
+ else
129
+ record.eventPort = port;
130
+ write();
131
+ };
132
+ return { record, stop, setRole, setEventPort };
133
+ }
134
+ /** The pid of a live launchd agent other than `exceptPid`, or null. Cheap: no build hashing. */
135
+ export function liveDaemonPid(exceptPid = process.pid) {
136
+ const dir = registryDir();
137
+ if (!existsSync(dir))
138
+ return null;
139
+ for (const f of readdirSync(dir)) {
140
+ if (!f.endsWith(".json"))
141
+ continue;
142
+ try {
143
+ const rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
144
+ if (rec.daemon && rec.pid !== exceptPid && isAlive(rec.pid))
145
+ return rec.pid;
146
+ }
147
+ catch {
148
+ /* a record being rewritten: the next tick reads it */
149
+ }
150
+ }
151
+ return null;
125
152
  }
126
153
  /** Read every record, drop the dead ones, compare builds with the files on disk now. */
127
154
  export function listServers() {
@@ -164,6 +191,15 @@ export function listServers() {
164
191
  }
165
192
  // Only servers that index on their own cost a re-index per doc change; readers of a shared
166
193
  // index do not. [LOCK] [ONE-INDEXER-MANY-READERS]
194
+ // [LOCK] [EVENT-PORT-BELONGS-TO-THE-DAEMON]: say who receives Claude Code and browser events.
195
+ const holder = report.servers.find((s) => typeof s.eventPort === "number");
196
+ const daemon = report.servers.find((s) => s.daemon);
197
+ if (holder?.staleBuild) {
198
+ report.warnings.push(`pid ${holder.pid} holds the event port :${holder.eventPort} on an old build: the redaction that guards the audit log runs that build (restart it, or let the launchd agent take the port)`);
199
+ }
200
+ if (holder && daemon && holder.pid !== daemon.pid) {
201
+ report.warnings.push(`the event port :${holder.eventPort} is held by pid ${holder.pid}, not by the launchd agent pid ${daemon.pid}; it hands over within seconds on 2.10.0 and later`);
202
+ }
167
203
  const indexing = report.servers.filter((s) => s.role !== "reader");
168
204
  if (indexing.length > SERVER_COUNT_WARN) {
169
205
  report.warnings.push(`${indexing.length} of ${report.servers.length} servers index on their own; every doc change makes each of them re-index the corpus (${SERVER_COUNT_WARN} is the comfortable ceiling; CONTEXTENGINE_SHARED_INDEX=1 makes all but one per corpus readers)`);
@@ -205,7 +241,7 @@ export function formatServers(report, home = homedir(), opts = {}) {
205
241
  for (const s of report.servers) {
206
242
  const t = s.started.slice(11, 19) + "Z";
207
243
  const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
208
- const role = s.role ? ` ${s.role.padEnd(7)} corpus ${s.corpus ?? "?"}` : "";
244
+ const role = (s.role ? ` ${s.role.padEnd(7)} corpus ${s.corpus ?? "?"}` : "") + (s.daemon ? " launchd agent" : "") + (s.eventPort ? ` holds :${s.eventPort}` : "");
209
245
  let cost = "";
210
246
  if (opts.cost) {
211
247
  const c = processCost(s.pid);
@@ -0,0 +1,10 @@
1
+ /** The trusted project names, lowercased. Seeds the file from `seed()` the first time. */
2
+ export declare function trustedProjects(seed: () => string[]): Set<string>;
3
+ export declare function listTrusted(): string[];
4
+ /** Adds projects; returns the resulting list. */
5
+ export declare function trustProjects(names: string[], seed?: () => string[]): string[];
6
+ /** Removes projects (case-insensitive); returns the resulting list. */
7
+ export declare function untrustProjects(names: string[]): string[];
8
+ /** Cheap check: does this doc look like it holds marked learnings (for the "not imported" hint)? */
9
+ export declare function looksLikeMarkedLearnings(content: string): boolean;
10
+ //# sourceMappingURL=trusted-projects.d.ts.map