@papicandela/mcx-core 0.2.2 → 0.2.6

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.
@@ -120,16 +120,19 @@ export class BunWorkerSandbox implements ISandbox {
120
120
  const worker = new Worker(url);
121
121
 
122
122
  let resolved = false;
123
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
124
+
123
125
  const cleanup = () => {
124
126
  if (!resolved) {
125
127
  resolved = true;
128
+ if (timeoutId) clearTimeout(timeoutId);
126
129
  worker.terminate();
127
130
  URL.revokeObjectURL(url);
128
131
  }
129
132
  };
130
133
 
131
134
  // Timeout handler
132
- const timeoutId = setTimeout(() => {
135
+ timeoutId = setTimeout(() => {
133
136
  if (!resolved) {
134
137
  cleanup();
135
138
  resolve({
@@ -142,6 +145,9 @@ export class BunWorkerSandbox implements ISandbox {
142
145
  }, this.config.timeout);
143
146
 
144
147
  worker.onmessage = async (event: MessageEvent) => {
148
+ // Guard against stale messages after resolution
149
+ if (resolved) return;
150
+
145
151
  const { type, ...data } = event.data;
146
152
 
147
153
  if (type === "ready") {
@@ -164,7 +170,6 @@ export class BunWorkerSandbox implements ISandbox {
164
170
  }
165
171
 
166
172
  else if (type === "result") {
167
- clearTimeout(timeoutId);
168
173
  cleanup();
169
174
  resolve({
170
175
  success: data.success,
@@ -177,7 +182,6 @@ export class BunWorkerSandbox implements ISandbox {
177
182
  };
178
183
 
179
184
  worker.onerror = (error: ErrorEvent) => {
180
- clearTimeout(timeoutId);
181
185
  cleanup();
182
186
  resolve({
183
187
  success: false,
@@ -211,11 +215,27 @@ export class BunWorkerSandbox implements ISandbox {
211
215
  const pendingCalls = new Map();
212
216
  let callId = 0;
213
217
 
218
+ // Safe stringify that handles BigInt and circular refs
219
+ const safeStr = (val) => {
220
+ if (typeof val !== 'object' || val === null) return String(val);
221
+ try {
222
+ const seen = new WeakSet();
223
+ return JSON.stringify(val, (k, v) => {
224
+ if (typeof v === 'bigint') return v.toString() + 'n';
225
+ if (typeof v === 'object' && v !== null) {
226
+ if (seen.has(v)) return '[Circular]';
227
+ seen.add(v);
228
+ }
229
+ return v;
230
+ });
231
+ } catch { return String(val); }
232
+ };
233
+
214
234
  const console = {
215
- log: (...args) => logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')),
216
- warn: (...args) => logs.push('[WARN] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')),
217
- error: (...args) => logs.push('[ERROR] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')),
218
- info: (...args) => logs.push('[INFO] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')),
235
+ log: (...args) => logs.push(args.map(safeStr).join(' ')),
236
+ warn: (...args) => logs.push('[WARN] ' + args.map(safeStr).join(' ')),
237
+ error: (...args) => logs.push('[ERROR] ' + args.map(safeStr).join(' ')),
238
+ info: (...args) => logs.push('[INFO] ' + args.map(safeStr).join(' ')),
219
239
  };
220
240
  globalThis.console = console;
221
241
 
@@ -265,25 +285,73 @@ export class BunWorkerSandbox implements ISandbox {
265
285
  return arr.slice(0, n);
266
286
  };
267
287
 
288
+ // SECURITY: Reserved keys that must not be overwritten by user-provided variables/globals
289
+ const RESERVED_KEYS = new Set([
290
+ 'onmessage', 'postMessage', 'close', 'terminate', 'self',
291
+ 'constructor', 'prototype', '__proto__',
292
+ 'pendingCalls', 'callId', 'logs', 'console', 'adapters',
293
+ 'fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource',
294
+ 'pick', 'table', 'count', 'sum', 'first', 'safeStr'
295
+ ]);
296
+
268
297
  self.onmessage = async (event) => {
269
298
  const { type, data } = event.data;
270
299
 
271
300
  if (type === 'init') {
272
301
  const { variables, adapterMethods, globals } = data;
273
302
 
303
+ // Inject user variables (skip reserved keys to prevent internal state corruption)
274
304
  for (const [key, value] of Object.entries(variables || {})) {
305
+ if (RESERVED_KEYS.has(key)) {
306
+ logs.push('[WARN] Skipped reserved variable key: ' + key);
307
+ continue;
308
+ }
275
309
  globalThis[key] = value;
276
310
  }
277
311
 
312
+ // Inject sandbox globals (skip reserved keys)
278
313
  for (const [key, value] of Object.entries(globals || {})) {
314
+ if (RESERVED_KEYS.has(key)) {
315
+ logs.push('[WARN] Skipped reserved globals key: ' + key);
316
+ continue;
317
+ }
279
318
  globalThis[key] = value;
280
319
  }
281
320
 
282
- globalThis.adapters = {};
321
+ // Levenshtein distance for fuzzy matching
322
+ const levenshtein = (a, b) => {
323
+ if (a.length === 0) return b.length;
324
+ if (b.length === 0) return a.length;
325
+ const matrix = [];
326
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
327
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
328
+ for (let i = 1; i <= b.length; i++) {
329
+ for (let j = 1; j <= a.length; j++) {
330
+ matrix[i][j] = b[i-1] === a[j-1]
331
+ ? matrix[i-1][j-1]
332
+ : Math.min(matrix[i-1][j-1] + 1, matrix[i][j-1] + 1, matrix[i-1][j] + 1);
333
+ }
334
+ }
335
+ return matrix[b.length][a.length];
336
+ };
337
+
338
+ // Find similar method names
339
+ const findSimilar = (name, methods, maxDist = 3) => {
340
+ const normalized = name.toLowerCase().replace(/[-_]/g, '');
341
+ return methods
342
+ .map(m => ({ method: m, dist: levenshtein(normalized, m.toLowerCase().replace(/[-_]/g, '')) }))
343
+ .filter(x => x.dist <= maxDist)
344
+ .sort((a, b) => a.dist - b.dist)
345
+ .slice(0, 3)
346
+ .map(x => x.method);
347
+ };
348
+
349
+ // Create adapter proxies with helpful error messages
350
+ const adaptersObj = {};
283
351
  for (const [adapterName, methods] of Object.entries(adapterMethods)) {
284
- const adapterObj = {};
352
+ const methodsImpl = {};
285
353
  for (const methodName of methods) {
286
- adapterObj[methodName] = async (...args) => {
354
+ methodsImpl[methodName] = async (...args) => {
287
355
  const id = ++callId;
288
356
  return new Promise((resolve, reject) => {
289
357
  pendingCalls.set(id, { resolve, reject });
@@ -297,9 +365,33 @@ export class BunWorkerSandbox implements ISandbox {
297
365
  });
298
366
  };
299
367
  }
300
- globalThis.adapters[adapterName] = adapterObj;
301
- globalThis[adapterName] = adapterObj;
368
+
369
+ // Use Proxy to intercept undefined method calls
370
+ const adapterProxy = new Proxy(methodsImpl, {
371
+ get(target, prop) {
372
+ if (prop in target) return target[prop];
373
+ if (typeof prop === 'symbol') return undefined;
374
+ const similar = findSimilar(String(prop), methods);
375
+ const suggestion = similar.length > 0
376
+ ? '. Did you mean: ' + similar.join(', ') + '?'
377
+ : '. Available: ' + methods.slice(0, 5).join(', ') + (methods.length > 5 ? '...' : '');
378
+ throw new Error(adapterName + '.' + String(prop) + ' is not a function' + suggestion);
379
+ }
380
+ });
381
+
382
+ adaptersObj[adapterName] = adapterProxy;
383
+ // Also expose at top level but as non-writable
384
+ Object.defineProperty(globalThis, adapterName, {
385
+ value: adapterProxy,
386
+ writable: false,
387
+ configurable: false
388
+ });
302
389
  }
390
+ Object.defineProperty(globalThis, 'adapters', {
391
+ value: adaptersObj,
392
+ writable: false,
393
+ configurable: false
394
+ });
303
395
 
304
396
  self.postMessage({ type: 'ready' });
305
397
  }
@@ -322,10 +414,12 @@ export class BunWorkerSandbox implements ISandbox {
322
414
  const result = await fn();
323
415
  self.postMessage({ type: 'result', success: true, value: result, logs });
324
416
  } catch (err) {
417
+ // Truncate stack to 5 lines to prevent context bloat
418
+ const stack = err.stack ? err.stack.split('\\n').slice(0, 5).join('\\n') : undefined;
325
419
  self.postMessage({
326
420
  type: 'result',
327
421
  success: false,
328
- error: { name: err.name, message: err.message, stack: err.stack },
422
+ error: { name: err.name, message: err.message, stack },
329
423
  logs
330
424
  });
331
425
  }
@@ -75,83 +75,139 @@ export function generateNetworkIsolationCode(policy: NetworkPolicy): string {
75
75
  }
76
76
 
77
77
  if (policy.mode === "blocked") {
78
+ // SECURITY: Wrap in IIFE to prevent user code from accessing __original_fetch
79
+ // Use Object.defineProperty with writable:false to prevent user code from overwriting
78
80
  return `
79
81
  // Network isolation: BLOCKED
80
- const __original_fetch = globalThis.fetch;
81
- globalThis.fetch = async function(url, options) {
82
- throw new Error('Network access is blocked in sandbox. Use adapters instead.');
83
- };
82
+ (function() {
83
+ const blockedFetch = async function() {
84
+ throw new Error('Network access is blocked in sandbox. Use adapters instead.');
85
+ };
86
+ Object.defineProperty(globalThis, 'fetch', {
87
+ value: blockedFetch,
88
+ writable: false,
89
+ configurable: false
90
+ });
91
+ })();
84
92
 
85
93
  // Block XMLHttpRequest
86
- globalThis.XMLHttpRequest = class {
87
- constructor() {
88
- throw new Error('XMLHttpRequest is blocked in sandbox.');
89
- }
90
- };
94
+ Object.defineProperty(globalThis, 'XMLHttpRequest', {
95
+ value: class {
96
+ constructor() {
97
+ throw new Error('XMLHttpRequest is blocked in sandbox.');
98
+ }
99
+ },
100
+ writable: false,
101
+ configurable: false
102
+ });
91
103
 
92
104
  // Block WebSocket
93
- globalThis.WebSocket = class {
94
- constructor(url) {
95
- throw new Error('WebSocket is blocked in sandbox.');
96
- }
97
- };
105
+ Object.defineProperty(globalThis, 'WebSocket', {
106
+ value: class {
107
+ constructor() {
108
+ throw new Error('WebSocket is blocked in sandbox.');
109
+ }
110
+ },
111
+ writable: false,
112
+ configurable: false
113
+ });
98
114
 
99
115
  // Block EventSource (SSE)
100
- globalThis.EventSource = class {
101
- constructor(url) {
102
- throw new Error('EventSource is blocked in sandbox.');
103
- }
104
- };
116
+ Object.defineProperty(globalThis, 'EventSource', {
117
+ value: class {
118
+ constructor() {
119
+ throw new Error('EventSource is blocked in sandbox.');
120
+ }
121
+ },
122
+ writable: false,
123
+ configurable: false
124
+ });
105
125
  `;
106
126
  }
107
127
 
108
128
  // mode === 'allowed' - whitelist specific domains
129
+ // SECURITY: Wrap in IIFE to prevent user code from accessing internals
130
+ // Use Object.defineProperty with writable:false to prevent user code from overwriting
109
131
  const domainsJson = JSON.stringify(policy.domains);
110
132
  return `
111
133
  // Network isolation: ALLOWED (whitelist)
112
- const __allowed_domains = ${domainsJson};
113
- const __original_fetch = globalThis.fetch;
134
+ (function() {
135
+ const _domains = ${domainsJson};
136
+ const _real_fetch = globalThis.fetch;
114
137
 
115
- function __isUrlAllowed(url) {
116
- try {
117
- const hostname = new URL(url).hostname;
118
- return __allowed_domains.some(d => hostname === d || hostname.endsWith('.' + d));
119
- } catch {
120
- return false;
138
+ // Block private/link-local IPs to prevent DNS rebinding attacks
139
+ function _isPrivateIp(hostname) {
140
+ return /^(localhost|127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|169\\.254\\.|\\[::1\\]|\\[fc|\\[fd)/.test(hostname);
121
141
  }
122
- }
123
142
 
124
- globalThis.fetch = async function(url, options) {
125
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
126
- if (!__isUrlAllowed(urlStr)) {
127
- const hostname = new URL(urlStr).hostname;
128
- throw new Error(\`Network access blocked: \${hostname} not in allowed domains: \${__allowed_domains.join(', ')}\`);
143
+ function _isUrlAllowed(url) {
144
+ try {
145
+ const parsed = new URL(url);
146
+ // Only allow http/https protocols
147
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
148
+ const hostname = parsed.hostname;
149
+ if (!hostname || _isPrivateIp(hostname)) return false;
150
+ return _domains.some(d => d && (hostname === d || hostname.endsWith('.' + d)));
151
+ } catch {
152
+ return false;
153
+ }
129
154
  }
130
- return __original_fetch(url, options);
131
- };
132
155
 
133
- // Block XMLHttpRequest (not easily whitelistable)
134
- globalThis.XMLHttpRequest = class {
135
- constructor() {
136
- throw new Error('XMLHttpRequest is blocked. Use fetch() with allowed domains.');
137
- }
138
- };
156
+ const whitelistedFetch = async function(url, options) {
157
+ // Safely extract URL string from various input types
158
+ let urlStr;
159
+ try {
160
+ if (typeof url === 'string') urlStr = url;
161
+ else if (url instanceof URL) urlStr = url.toString();
162
+ else if (url && typeof url.url === 'string') urlStr = url.url;
163
+ else throw new Error('Invalid URL type');
164
+ } catch {
165
+ throw new Error('Network access blocked: could not determine request URL.');
166
+ }
167
+ if (!_isUrlAllowed(urlStr)) {
168
+ throw new Error('Network access blocked: domain not in allowed list.');
169
+ }
170
+ return _real_fetch(url, options);
171
+ };
172
+
173
+ Object.defineProperty(globalThis, 'fetch', {
174
+ value: whitelistedFetch,
175
+ writable: false,
176
+ configurable: false
177
+ });
178
+ })();
139
179
 
140
- // Block WebSocket (would need separate whitelist)
141
- globalThis.WebSocket = class {
142
- constructor(url) {
143
- if (!__isUrlAllowed(url)) {
144
- throw new Error('WebSocket blocked: domain not in allowed list.');
180
+ // Block XMLHttpRequest (not easily whitelistable)
181
+ Object.defineProperty(globalThis, 'XMLHttpRequest', {
182
+ value: class {
183
+ constructor() {
184
+ throw new Error('XMLHttpRequest is blocked. Use fetch() with allowed domains.');
145
185
  }
146
- throw new Error('WebSocket not supported in sandbox even for allowed domains.');
147
- }
148
- };
186
+ },
187
+ writable: false,
188
+ configurable: false
189
+ });
190
+
191
+ // Block WebSocket - opaque error to prevent allowlist enumeration
192
+ Object.defineProperty(globalThis, 'WebSocket', {
193
+ value: class {
194
+ constructor() {
195
+ throw new Error('WebSocket is not supported in sandbox.');
196
+ }
197
+ },
198
+ writable: false,
199
+ configurable: false
200
+ });
149
201
 
150
202
  // Block EventSource
151
- globalThis.EventSource = class {
152
- constructor(url) {
153
- throw new Error('EventSource is blocked in sandbox.');
154
- }
155
- };
203
+ Object.defineProperty(globalThis, 'EventSource', {
204
+ value: class {
205
+ constructor() {
206
+ throw new Error('EventSource is blocked in sandbox.');
207
+ }
208
+ },
209
+ writable: false,
210
+ configurable: false
211
+ });
156
212
  `;
157
213
  }
@@ -48,7 +48,8 @@ export function generateTypes(
48
48
  // Generate input interface for each tool with parameters
49
49
  for (const [toolName, tool] of Object.entries(adapter.tools)) {
50
50
  if (tool.parameters && Object.keys(tool.parameters).length > 0) {
51
- const inputTypeName = `${capitalize(safeName)}_${capitalize(toolName)}_Input`;
51
+ const safeToolNameForType = sanitizeIdentifier(toolName);
52
+ const inputTypeName = `${capitalize(safeName)}_${capitalize(safeToolNameForType)}_Input`;
52
53
  lines.push(generateInputInterface(inputTypeName, tool.parameters, includeDescriptions));
53
54
  lines.push("");
54
55
  }
@@ -56,17 +57,18 @@ export function generateTypes(
56
57
 
57
58
  // Generate adapter declaration
58
59
  if (includeDescriptions && adapter.description) {
59
- lines.push(`/** ${adapter.description} */`);
60
+ lines.push(`/** ${sanitizeJSDoc(adapter.description)} */`);
60
61
  }
61
62
  lines.push(`declare const ${safeName}: {`);
62
63
 
63
64
  for (const [toolName, tool] of Object.entries(adapter.tools)) {
64
65
  const safeToolName = sanitizeIdentifier(toolName);
65
66
  const hasParams = tool.parameters && Object.keys(tool.parameters).length > 0;
66
- const inputTypeName = `${capitalize(safeName)}_${capitalize(toolName)}_Input`;
67
+ // Use sanitized tool name in type name to ensure consistency
68
+ const inputTypeName = `${capitalize(safeName)}_${capitalize(safeToolName)}_Input`;
67
69
 
68
70
  if (includeDescriptions && tool.description) {
69
- lines.push(` /** ${tool.description} */`);
71
+ lines.push(` /** ${sanitizeJSDoc(tool.description)} */`);
70
72
  }
71
73
 
72
74
  const paramStr = hasParams ? `params: ${inputTypeName}` : "";
@@ -83,18 +85,76 @@ export function generateTypes(
83
85
 
84
86
  /**
85
87
  * Generate a compact type summary for token-constrained contexts.
86
- * Returns a condensed one-liner per adapter.
88
+ * Groups adapters by domain and shows method count.
89
+ * Use mcx_search to discover specific methods.
87
90
  *
88
91
  * @param adapters - Array of adapters
89
- * @returns Compact summary string
92
+ * @returns Compact summary string with domain hints
90
93
  */
91
94
  export function generateTypesSummary(adapters: Adapter[]): string {
92
- return adapters
93
- .map((adapter) => {
94
- const methods = Object.keys(adapter.tools).join(", ");
95
- return `${adapter.name}: { ${methods} }`;
96
- })
97
- .join("\n");
95
+ // Group adapters by domain
96
+ const byDomain = new Map<string, Adapter[]>();
97
+
98
+ for (const adapter of adapters) {
99
+ const domain = inferDomain(adapter);
100
+ if (!byDomain.has(domain)) {
101
+ byDomain.set(domain, []);
102
+ }
103
+ byDomain.get(domain)!.push(adapter);
104
+ }
105
+
106
+ // If only one domain or fewer than 4 adapters, simple list
107
+ if (byDomain.size <= 1 || adapters.length < 4) {
108
+ return adapters
109
+ .map((adapter) => {
110
+ const count = Object.keys(adapter.tools).length;
111
+ return `- ${adapter.name} (${count} methods)`;
112
+ })
113
+ .join("\n");
114
+ }
115
+
116
+ // Group by domain for better discoverability
117
+ const lines: string[] = [];
118
+ for (const [domain, domainAdapters] of byDomain) {
119
+ const adapterList = domainAdapters
120
+ .map((a) => `${a.name}(${Object.keys(a.tools).length})`)
121
+ .join(", ");
122
+ lines.push(`[${domain}] ${adapterList}`);
123
+ }
124
+ return lines.join("\n");
125
+ }
126
+
127
+ /**
128
+ * Infer domain from adapter name/description if not explicitly set.
129
+ * Returns the adapter's explicit domain if set, otherwise infers from name/description.
130
+ */
131
+ export function inferDomain(adapter: Adapter): string {
132
+ if (adapter.domain) return adapter.domain;
133
+
134
+ const name = adapter.name.toLowerCase();
135
+ const desc = (adapter.description || "").toLowerCase();
136
+ const combined = `${name} ${desc}`;
137
+
138
+ const domains: Record<string, string[]> = {
139
+ payments: ["stripe", "paypal", "square", "payment", "checkout", "billing", "invoice"],
140
+ database: ["supabase", "postgres", "mysql", "mongodb", "redis", "database", "sql", "query"],
141
+ email: ["sendgrid", "mailgun", "postmark", "email", "smtp", "mail"],
142
+ storage: ["s3", "cloudflare", "storage", "blob", "file", "upload"],
143
+ auth: ["auth", "oauth", "login", "jwt", "clerk", "auth0"],
144
+ ai: ["openai", "anthropic", "claude", "gpt", "llm", "ai", "ml"],
145
+ messaging: ["slack", "discord", "telegram", "twilio", "sms", "chat"],
146
+ crm: ["hubspot", "salesforce", "crm", "customer"],
147
+ analytics: ["analytics", "metrics", "tracking", "mixpanel", "amplitude"],
148
+ devtools: ["github", "gitlab", "jira", "linear", "chrome", "devtools", "ci", "cd"],
149
+ };
150
+
151
+ for (const [domain, keywords] of Object.entries(domains)) {
152
+ if (keywords.some((k) => combined.includes(k))) {
153
+ return domain;
154
+ }
155
+ }
156
+
157
+ return "general";
98
158
  }
99
159
 
100
160
  /**
@@ -108,13 +168,16 @@ function generateInputInterface(
108
168
  const lines: string[] = [`interface ${typeName} {`];
109
169
 
110
170
  for (const [paramName, param] of Object.entries(parameters)) {
171
+ // Sanitize parameter name to prevent injection
172
+ const safeParamName = sanitizeIdentifier(paramName);
173
+
111
174
  if (includeDescriptions && param.description) {
112
- lines.push(` /** ${param.description} */`);
175
+ lines.push(` /** ${sanitizeJSDoc(param.description)} */`);
113
176
  }
114
177
 
115
178
  const tsType = paramTypeToTS(param.type);
116
- const optional = param.required === false ? "?" : "";
117
- lines.push(` ${paramName}${optional}: ${tsType};`);
179
+ const optional = param.required === true ? "" : "?";
180
+ lines.push(` ${safeParamName}${optional}: ${tsType};`);
118
181
  }
119
182
 
120
183
  lines.push("}");
@@ -170,3 +233,11 @@ export function sanitizeIdentifier(name: string): string {
170
233
  function capitalize(str: string): string {
171
234
  return str.charAt(0).toUpperCase() + str.slice(1);
172
235
  }
236
+
237
+ /**
238
+ * Sanitize text for use in JSDoc comments.
239
+ * Prevents comment injection via `*​/` sequences.
240
+ */
241
+ function sanitizeJSDoc(text: string): string {
242
+ return text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
243
+ }
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ export interface ParameterDefinition {
12
12
  description?: string;
13
13
  required?: boolean;
14
14
  default?: unknown;
15
+ /** Example value for this parameter (helps LLMs understand expected format) */
16
+ example?: unknown;
15
17
  }
16
18
 
17
19
  /**
@@ -33,6 +35,8 @@ export interface Adapter<TTools extends Record<string, AdapterTool> = Record<str
33
35
  name: string;
34
36
  description?: string;
35
37
  version?: string;
38
+ /** Domain/category for tool discovery (e.g., 'payments', 'database', 'email') */
39
+ domain?: string;
36
40
  tools: TTools;
37
41
  dispose?: () => Promise<void> | void;
38
42
  }