@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.
- package/CHANGELOG.md +170 -1
- package/dist/activation.d.ts +23 -1
- package/dist/activation.js +112 -21
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +6 -5
- package/dist/audit.d.ts +70 -3
- package/dist/audit.js +664 -82
- package/dist/ce-home.d.ts +7 -0
- package/dist/ce-home.js +45 -0
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +128 -23
- package/dist/collectors.d.ts +3 -0
- package/dist/collectors.js +37 -6
- package/dist/community-export.js +19 -22
- package/dist/community-sync.d.ts +3 -0
- package/dist/community-sync.js +41 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -3
- package/dist/firewall.js +7 -3
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/framing.d.ts +2 -0
- package/dist/framing.js +13 -0
- package/dist/http-server.d.ts +34 -5
- package/dist/http-server.js +246 -57
- package/dist/index.js +105 -35
- package/dist/install-autostart.js +38 -17
- package/dist/install-claude-hook.d.ts +1 -0
- package/dist/install-claude-hook.js +135 -27
- package/dist/learnings.d.ts +1 -0
- package/dist/learnings.js +18 -1
- package/dist/license-sig.d.ts +9 -2
- package/dist/license-sig.js +35 -7
- package/dist/secret-shapes.d.ts +19 -0
- package/dist/secret-shapes.js +30 -2
- package/dist/server-registry.d.ts +8 -0
- package/dist/server-registry.js +38 -2
- package/dist/trusted-projects.d.ts +10 -0
- package/dist/trusted-projects.js +77 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
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] =
|
|
476
|
+
settings.hooks[kind] = kept;
|
|
374
477
|
}
|
|
375
478
|
}
|
|
376
|
-
|
|
377
|
-
|
|
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
|
}
|
package/dist/learnings.d.ts
CHANGED
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.
|
package/dist/license-sig.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
42
|
-
*
|
|
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;
|
package/dist/license-sig.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
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).
|
|
30
|
-
// override
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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 =
|
|
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
|
}
|
package/dist/secret-shapes.d.ts
CHANGED
|
@@ -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;
|
package/dist/secret-shapes.js
CHANGED
|
@@ -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
|
-
|
|
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). */
|
package/dist/server-registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|