@compr/opscontext-mcp 2.9.1 → 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.
- package/CHANGELOG.md +122 -0
- 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 +1 -1
- 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 +75 -19
- 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/framing.d.ts +2 -0
- package/dist/framing.js +13 -0
- package/dist/http-server.d.ts +34 -5
- package/dist/http-server.js +234 -55
- package/dist/index.js +64 -32
- 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 +27 -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
|
@@ -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 {
|
|
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 =
|
|
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, "&").replace(/</g, "<");
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,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 = {};
|