@ganju/utils 0.0.14 → 0.0.15
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/dist/channelCustomer.d.ts +99 -0
- package/dist/channelCustomer.d.ts.map +1 -0
- package/dist/channelCustomer.js +533 -0
- package/dist/channelDebounce.d.ts +7 -0
- package/dist/channelDebounce.d.ts.map +1 -1
- package/dist/chunkOverlap.d.ts +2 -0
- package/dist/chunkOverlap.d.ts.map +1 -0
- package/dist/chunkOverlap.js +9 -0
- package/dist/chunking.d.ts.map +1 -1
- package/dist/chunking.js +24 -4
- package/dist/citations.d.ts +28 -0
- package/dist/citations.d.ts.map +1 -0
- package/dist/citations.js +111 -0
- package/dist/constants.d.ts +26 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +107 -9
- package/dist/index.d.ts +263 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -0
- package/dist/jsonSchemaToZodShape.d.ts +5 -1
- package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
- package/dist/jsonSchemaToZodShape.js +21 -9
- package/dist/pagination.d.ts +47 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +160 -0
- package/dist/processQueueBatch.d.ts +6 -1
- package/dist/processQueueBatch.d.ts.map +1 -1
- package/dist/processQueueBatch.js +23 -10
- package/dist/queueFailure.d.ts +14 -0
- package/dist/queueFailure.d.ts.map +1 -0
- package/dist/queueFailure.js +43 -0
- package/dist/resourceRerank.d.ts.map +1 -1
- package/dist/resourceRerank.js +3 -1
- package/dist/schema.d.ts +90 -26
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +87 -6
- package/dist/ssrf.d.ts +1 -0
- package/dist/ssrf.d.ts.map +1 -1
- package/dist/ssrf.js +48 -12
- package/dist/toolConfirmation.d.ts +48 -0
- package/dist/toolConfirmation.d.ts.map +1 -1
- package/dist/toolConfirmation.js +75 -1
- package/dist/toolCount.d.ts +13 -0
- package/dist/toolCount.d.ts.map +1 -0
- package/dist/toolCount.js +45 -0
- package/dist/toolLint.d.ts +1 -1
- package/dist/toolLint.d.ts.map +1 -1
- package/dist/toolLint.js +20 -0
- package/dist/untrustedContent.d.ts +135 -0
- package/dist/untrustedContent.d.ts.map +1 -0
- package/dist/untrustedContent.js +402 -0
- package/package.json +2 -1
- package/src/channelCustomer.ts +604 -0
- package/src/channelDebounce.ts +17 -0
- package/src/chunkOverlap.ts +6 -0
- package/src/chunking.ts +25 -4
- package/src/citations.ts +127 -0
- package/src/constants.ts +111 -9
- package/src/index.ts +152 -2
- package/src/jsonSchemaToZodShape.ts +31 -13
- package/src/pagination.ts +193 -0
- package/src/processQueueBatch.ts +42 -12
- package/src/queueFailure.ts +63 -0
- package/src/resourceRerank.ts +5 -1
- package/src/schema.ts +100 -4
- package/src/ssrf.ts +40 -11
- package/src/toolConfirmation.ts +123 -0
- package/src/toolCount.ts +53 -0
- package/src/toolLint.ts +27 -0
- package/src/untrustedContent.ts +476 -0
package/src/ssrf.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Literal-host SSRF screening shared by the proxied tool dispatchers
|
|
2
|
-
// (http-endpoint, mcp-proxy). The Workers runtime can't resolve DNS, so we can
|
|
2
|
+
// (http-endpoint, mcp-proxy) and the website crawler. The Workers runtime can't resolve DNS, so we can
|
|
3
3
|
// only screen literal hosts and IPs (not DNS rebinding). Private/loopback/
|
|
4
4
|
// link-local ranges are always rejected; callers may further narrow with an
|
|
5
5
|
// allowlist.
|
|
@@ -13,22 +13,51 @@ export const ipv4InPrivateRange = (host: string): boolean => {
|
|
|
13
13
|
if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata)
|
|
14
14
|
if (a === 192 && b === 168) return true; // private
|
|
15
15
|
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
16
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
|
16
17
|
return false;
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
// The IPv4 address an IPv4-mapped IPv6 literal reaches. URL parsing rewrites
|
|
21
|
+
// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, so both spellings are read.
|
|
22
|
+
const mappedIpv4 = (host: string): string | null => {
|
|
23
|
+
if (!host.startsWith('::ffff:')) return null;
|
|
24
|
+
const rest = host.slice('::ffff:'.length);
|
|
25
|
+
if (rest.includes('.')) return rest;
|
|
26
|
+
const match = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const hi = Number.parseInt(match[1], 16);
|
|
29
|
+
const lo = Number.parseInt(match[2], 16);
|
|
30
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
|
|
31
|
+
};
|
|
32
|
+
|
|
19
33
|
export const isBlockedHost = (hostname: string): boolean => {
|
|
20
|
-
const host = hostname
|
|
34
|
+
const host = hostname
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/^\[|\]$/g, '')
|
|
37
|
+
.replace(/\.$/, '');
|
|
21
38
|
if (!host) return true;
|
|
22
39
|
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
|
23
|
-
if (host
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
host
|
|
27
|
-
|
|
28
|
-
host
|
|
29
|
-
|
|
40
|
+
if (host.includes(':')) {
|
|
41
|
+
// An IPv6 literal. Its prefixes are only checked here: a domain name such
|
|
42
|
+
// as fdic.gov or fcbarcelona.com starts with the same letters.
|
|
43
|
+
if (host === '::1' || host === '::') return true;
|
|
44
|
+
// Link-local (fe80::/10) and unique-local (fc00::/7).
|
|
45
|
+
if (/^fe[89ab]/.test(host) || /^f[cd]/.test(host)) return true;
|
|
46
|
+
const mapped = mappedIpv4(host);
|
|
47
|
+
return mapped !== null && ipv4InPrivateRange(mapped);
|
|
48
|
+
}
|
|
49
|
+
return ipv4InPrivateRange(host);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Whether a URL may not be fetched on a user's behalf: not http(s), not a URL
|
|
53
|
+
// at all, or pointing at a private, loopback or link-local host.
|
|
54
|
+
export const isBlockedUrl = (url: string): boolean => {
|
|
55
|
+
let parsed: URL;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(url);
|
|
58
|
+
} catch {
|
|
30
59
|
return true;
|
|
31
60
|
}
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
61
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true;
|
|
62
|
+
return isBlockedHost(parsed.hostname);
|
|
34
63
|
};
|
package/src/toolConfirmation.ts
CHANGED
|
@@ -274,6 +274,94 @@ export const isSensitiveTool = (
|
|
|
274
274
|
): boolean =>
|
|
275
275
|
annotations?.readOnlyHint !== true && annotations?.destructiveHint !== false;
|
|
276
276
|
|
|
277
|
+
// How far a proxied tool's own hints are believed, from its install's config:
|
|
278
|
+
// the remote's read-only / destructive hints only when the owner chose to
|
|
279
|
+
// trust that server, and the owner's own "only reads" mark on a tool always.
|
|
280
|
+
export interface ProxiedToolTrust {
|
|
281
|
+
trustHints: boolean;
|
|
282
|
+
readOnly: boolean;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const proxiedToolTrust = (
|
|
286
|
+
config:
|
|
287
|
+
| { trustAnnotations?: unknown; readOnlyTools?: unknown }
|
|
288
|
+
| null
|
|
289
|
+
| undefined,
|
|
290
|
+
remoteToolName: string
|
|
291
|
+
): ProxiedToolTrust => ({
|
|
292
|
+
trustHints: config?.trustAnnotations === true,
|
|
293
|
+
readOnly:
|
|
294
|
+
Array.isArray(config?.readOnlyTools) &&
|
|
295
|
+
config.readOnlyTools.includes(remoteToolName)
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The annotations a proxied tool is registered with. A remote server's hints
|
|
300
|
+
* are its own claim about itself, and one that marks a delete read-only would
|
|
301
|
+
* skip every confirmation — so unless the organization chose to trust that
|
|
302
|
+
* server, the read-only and destructive hints are dropped and the tool reads
|
|
303
|
+
* as sensitive by the defaults above. A tool the owner marked as only reading
|
|
304
|
+
* is read-only whatever the remote says, trusted or not. Clients see the
|
|
305
|
+
* same, so they ask about the same tools. The harmless parts (title,
|
|
306
|
+
* openWorldHint, idempotentHint) pass through.
|
|
307
|
+
*/
|
|
308
|
+
export const proxiedToolAnnotations = <
|
|
309
|
+
T extends { readOnlyHint?: boolean; destructiveHint?: boolean }
|
|
310
|
+
>(
|
|
311
|
+
annotations: T | null | undefined,
|
|
312
|
+
trust: ProxiedToolTrust
|
|
313
|
+
): Partial<T> | undefined => {
|
|
314
|
+
const result = { ...annotations } as Partial<T>;
|
|
315
|
+
if (trust.readOnly) {
|
|
316
|
+
delete result.destructiveHint;
|
|
317
|
+
return { ...result, readOnlyHint: true };
|
|
318
|
+
}
|
|
319
|
+
if (!trust.trustHints) {
|
|
320
|
+
delete result.readOnlyHint;
|
|
321
|
+
delete result.destructiveHint;
|
|
322
|
+
}
|
|
323
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// The install-config keys that hold the owner's trust marks above.
|
|
327
|
+
const PROXIED_TOOL_MARK_KEYS = ['trustAnnotations', 'readOnlyTools'] as const;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* An install's new config with the owner's trust marks carried over from the
|
|
331
|
+
* stored one wherever the new one doesn't say. A save that only knew about
|
|
332
|
+
* the allow-lists (an older dashboard, a script) must not quietly turn the
|
|
333
|
+
* marks off; one that sends them, even as false or empty, decides.
|
|
334
|
+
*/
|
|
335
|
+
export const keepProxiedToolMarks = (
|
|
336
|
+
stored: unknown,
|
|
337
|
+
incoming: unknown
|
|
338
|
+
): unknown => {
|
|
339
|
+
if (
|
|
340
|
+
!stored ||
|
|
341
|
+
typeof stored !== 'object' ||
|
|
342
|
+
!incoming ||
|
|
343
|
+
typeof incoming !== 'object'
|
|
344
|
+
) {
|
|
345
|
+
return incoming;
|
|
346
|
+
}
|
|
347
|
+
const merged = { ...(incoming as Record<string, unknown>) };
|
|
348
|
+
for (const key of PROXIED_TOOL_MARK_KEYS) {
|
|
349
|
+
if (merged[key] === undefined && key in stored) {
|
|
350
|
+
merged[key] = (stored as Record<string, unknown>)[key];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return merged;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** Whether a proxied tool is confirmed, decided as its registration is. */
|
|
357
|
+
export const isSensitiveProxiedTool = (
|
|
358
|
+
annotations:
|
|
359
|
+
| { readOnlyHint?: boolean; destructiveHint?: boolean }
|
|
360
|
+
| null
|
|
361
|
+
| undefined,
|
|
362
|
+
trust: ProxiedToolTrust
|
|
363
|
+
): boolean => isSensitiveTool(proxiedToolAnnotations(annotations, trust));
|
|
364
|
+
|
|
277
365
|
// JSON with keys sorted at every depth, so two argument objects that say the
|
|
278
366
|
// same thing serialize the same whatever order the model emitted them in.
|
|
279
367
|
export const stableJson = (value: unknown): string => {
|
|
@@ -289,3 +377,38 @@ export const stableJson = (value: unknown): string => {
|
|
|
289
377
|
}
|
|
290
378
|
return JSON.stringify(value) ?? 'null';
|
|
291
379
|
};
|
|
380
|
+
|
|
381
|
+
export type ToolApprovalStatus =
|
|
382
|
+
| 'pending'
|
|
383
|
+
| 'approved'
|
|
384
|
+
| 'denied'
|
|
385
|
+
| 'used'
|
|
386
|
+
| 'expired';
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Where an approval stands. Spent and refused come first, since an answer
|
|
390
|
+
* stays the answer after the window closes; past its time, one never answered
|
|
391
|
+
* or never used can no longer be.
|
|
392
|
+
*/
|
|
393
|
+
export const toolApprovalStatus = (
|
|
394
|
+
approval: {
|
|
395
|
+
expiresAt: Date;
|
|
396
|
+
approvedAt: Date | null;
|
|
397
|
+
deniedAt: Date | null;
|
|
398
|
+
usedAt: Date | null;
|
|
399
|
+
},
|
|
400
|
+
now: number = Date.now()
|
|
401
|
+
): ToolApprovalStatus => {
|
|
402
|
+
if (approval.usedAt) return 'used';
|
|
403
|
+
if (approval.deniedAt) return 'denied';
|
|
404
|
+
if (approval.expiresAt.getTime() <= now) return 'expired';
|
|
405
|
+
return approval.approvedAt ? 'approved' : 'pending';
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/** The dashboard page where the user answers one approval. */
|
|
409
|
+
export const toolApprovalUrl = (
|
|
410
|
+
webUrl: string,
|
|
411
|
+
path: string,
|
|
412
|
+
approvalId: string
|
|
413
|
+
): string =>
|
|
414
|
+
`${webUrl.replace(/\/+$/, '')}${path}/${encodeURIComponent(approvalId)}`;
|
package/src/toolCount.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// How many tools an install puts in front of a model, which is what the tool
|
|
2
|
+
// limit counts. Imports nothing, so node --test runs it directly.
|
|
3
|
+
|
|
4
|
+
// The same values as constants.TOOL_DEFINITION_KEY_MCP_PROXY and
|
|
5
|
+
// TOOL_DEFINITION_KEY_CUSTOM_CODE, written out rather than imported, since
|
|
6
|
+
// this file imports nothing.
|
|
7
|
+
const MCP_PROXY_TOOL_KEY = 'mcp-proxy';
|
|
8
|
+
const CUSTOM_CODE_TOOL_KEY = 'custom-code';
|
|
9
|
+
|
|
10
|
+
// The tools an allow-list keeps out of `names`. An empty or absent list keeps
|
|
11
|
+
// all of them, as the MCP boot loop reads it.
|
|
12
|
+
const allowedCount = (names: string[], config: unknown): number => {
|
|
13
|
+
const allowedTools = (config as { allowedTools?: unknown } | null)
|
|
14
|
+
?.allowedTools;
|
|
15
|
+
if (!Array.isArray(allowedTools) || allowedTools.length === 0) {
|
|
16
|
+
return names.length;
|
|
17
|
+
}
|
|
18
|
+
const allowed = new Set(allowedTools);
|
|
19
|
+
return names.filter(name => allowed.has(name)).length;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An mcp-proxy row exposes each discovered remote tool its allow-list keeps,
|
|
24
|
+
* so a single GitHub install can be dozens of tools. A custom-code row exposes
|
|
25
|
+
* the functions of its published version the allow-list keeps — pass their
|
|
26
|
+
* names as `customCodeFunctions`; without them the row counts as one. Every
|
|
27
|
+
* other row is one tool.
|
|
28
|
+
*/
|
|
29
|
+
export const countInstallTools = (
|
|
30
|
+
install: {
|
|
31
|
+
toolKey: string;
|
|
32
|
+
config: unknown;
|
|
33
|
+
metadata: unknown;
|
|
34
|
+
},
|
|
35
|
+
customCodeFunctions?: string[]
|
|
36
|
+
): number => {
|
|
37
|
+
if (install.toolKey === CUSTOM_CODE_TOOL_KEY) {
|
|
38
|
+
return customCodeFunctions
|
|
39
|
+
? allowedCount(customCodeFunctions, install.config)
|
|
40
|
+
: 1;
|
|
41
|
+
}
|
|
42
|
+
if (install.toolKey !== MCP_PROXY_TOOL_KEY) return 1;
|
|
43
|
+
|
|
44
|
+
const discovered = (
|
|
45
|
+
install.metadata as { discovery?: { tools?: unknown } } | null
|
|
46
|
+
)?.discovery?.tools;
|
|
47
|
+
if (!Array.isArray(discovered)) return 0;
|
|
48
|
+
|
|
49
|
+
const names = discovered
|
|
50
|
+
.map(tool => (tool as { name?: unknown } | null)?.name)
|
|
51
|
+
.filter((name): name is string => typeof name === 'string');
|
|
52
|
+
return allowedCount(names, install.config);
|
|
53
|
+
};
|
package/src/toolLint.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const TOOL_LINT_RULES = [
|
|
|
32
32
|
'no-usage-guidance',
|
|
33
33
|
'missing-annotations',
|
|
34
34
|
'undescribed-input',
|
|
35
|
+
'reserved-input',
|
|
35
36
|
'overlapping-tools',
|
|
36
37
|
'too-many-tools'
|
|
37
38
|
] as const;
|
|
@@ -47,6 +48,7 @@ export interface ToolLintFinding {
|
|
|
47
48
|
// pair is reported once, on the first of the two.
|
|
48
49
|
alike?: string[];
|
|
49
50
|
// `undescribed-input`: the properties with no description, as dotted paths.
|
|
51
|
+
// `reserved-input`: the top-level properties whose names the server keeps.
|
|
50
52
|
paths?: string[];
|
|
51
53
|
// `too-many-tools`: how many are enabled, and the number past which it warns.
|
|
52
54
|
count?: number;
|
|
@@ -226,6 +228,21 @@ const undescribedPaths = (schema: unknown, prefix = ''): string[] => {
|
|
|
226
228
|
return paths;
|
|
227
229
|
};
|
|
228
230
|
|
|
231
|
+
// Top-level input names the server takes for itself. `confirmation` carries
|
|
232
|
+
// the token a sensitive tool's second call brings back: the server replaces a
|
|
233
|
+
// property of that name and strips it before the tool runs, so it never
|
|
234
|
+
// reaches the tool.
|
|
235
|
+
// Kept here rather than imported from constants (MCP_TOOL_CONFIRMATION_ARG),
|
|
236
|
+
// since this file imports nothing.
|
|
237
|
+
const RESERVED_INPUT_NAMES = ['confirmation'];
|
|
238
|
+
|
|
239
|
+
const reservedPaths = (schema: unknown): string[] =>
|
|
240
|
+
isObject(schema) && isObject(schema.properties)
|
|
241
|
+
? RESERVED_INPUT_NAMES.filter(name =>
|
|
242
|
+
Object.prototype.hasOwnProperty.call(schema.properties, name)
|
|
243
|
+
)
|
|
244
|
+
: [];
|
|
245
|
+
|
|
229
246
|
const quoted = (name: string) => `"${name}"`;
|
|
230
247
|
|
|
231
248
|
/**
|
|
@@ -302,6 +319,16 @@ export const lintTools = (
|
|
|
302
319
|
});
|
|
303
320
|
}
|
|
304
321
|
|
|
322
|
+
const reserved = reservedPaths(tool.inputSchema);
|
|
323
|
+
if (reserved.length > 0) {
|
|
324
|
+
findings.push({
|
|
325
|
+
rule: 'reserved-input',
|
|
326
|
+
tool: tool.name,
|
|
327
|
+
paths: reserved,
|
|
328
|
+
message: `${quoted(tool.name)} has an input named ${reserved.map(quoted).join(', ')}, which the server keeps for confirming sensitive actions: on a tool it confirms, that property is replaced and its value never reaches the tool. Rename it.`
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
305
332
|
// Each pair once — against the tools after this one, then the rest of the
|
|
306
333
|
// server — and one finding per tool, so twenty look-alikes are nineteen
|
|
307
334
|
// lines rather than a hundred and ninety.
|