@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/index.js +388 -113
- package/package.json +8 -2
- package/src/adapter.ts +8 -4
- package/src/config.ts +6 -0
- package/src/executor.ts +5 -3
- package/src/index.ts +1 -0
- package/src/sandbox/analyzer/analyzer.test.ts +3 -3
- package/src/sandbox/analyzer/analyzer.ts +2 -2
- package/src/sandbox/analyzer/rules/no-dangerous-globals.ts +135 -33
- package/src/sandbox/analyzer/rules/no-infinite-loop.ts +40 -13
- package/src/sandbox/bun-worker.ts +107 -13
- package/src/sandbox/network-policy.ts +110 -54
- package/src/type-generator.ts +86 -15
- package/src/types.ts +4 -0
|
@@ -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
|
-
|
|
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(
|
|
216
|
-
warn: (...args) => logs.push('[WARN] ' + args.map(
|
|
217
|
-
error: (...args) => logs.push('[ERROR] ' + args.map(
|
|
218
|
-
info: (...args) => logs.push('[INFO] ' + args.map(
|
|
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
|
-
|
|
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
|
|
352
|
+
const methodsImpl = {};
|
|
285
353
|
for (const methodName of methods) {
|
|
286
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
113
|
-
const
|
|
134
|
+
(function() {
|
|
135
|
+
const _domains = ${domainsJson};
|
|
136
|
+
const _real_fetch = globalThis.fetch;
|
|
114
137
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
141
|
-
globalThis
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
throw new Error('
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
}
|
package/src/type-generator.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
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 ===
|
|
117
|
-
lines.push(` ${
|
|
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
|
}
|