@ganju/utils 0.0.13 → 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.
Files changed (70) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +26 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +108 -10
  17. package/dist/index.d.ts +263 -31
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +70 -0
  20. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  21. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  22. package/dist/jsonSchemaToZodShape.js +21 -9
  23. package/dist/pagination.d.ts +47 -0
  24. package/dist/pagination.d.ts.map +1 -0
  25. package/dist/pagination.js +160 -0
  26. package/dist/processQueueBatch.d.ts +6 -1
  27. package/dist/processQueueBatch.d.ts.map +1 -1
  28. package/dist/processQueueBatch.js +23 -10
  29. package/dist/queueFailure.d.ts +14 -0
  30. package/dist/queueFailure.d.ts.map +1 -0
  31. package/dist/queueFailure.js +43 -0
  32. package/dist/resourceRerank.d.ts.map +1 -1
  33. package/dist/resourceRerank.js +3 -1
  34. package/dist/schema.d.ts +90 -26
  35. package/dist/schema.d.ts.map +1 -1
  36. package/dist/schema.js +87 -6
  37. package/dist/ssrf.d.ts +1 -0
  38. package/dist/ssrf.d.ts.map +1 -1
  39. package/dist/ssrf.js +48 -12
  40. package/dist/toolConfirmation.d.ts +48 -0
  41. package/dist/toolConfirmation.d.ts.map +1 -1
  42. package/dist/toolConfirmation.js +75 -1
  43. package/dist/toolCount.d.ts +13 -0
  44. package/dist/toolCount.d.ts.map +1 -0
  45. package/dist/toolCount.js +45 -0
  46. package/dist/toolLint.d.ts +1 -1
  47. package/dist/toolLint.d.ts.map +1 -1
  48. package/dist/toolLint.js +37 -3
  49. package/dist/untrustedContent.d.ts +135 -0
  50. package/dist/untrustedContent.d.ts.map +1 -0
  51. package/dist/untrustedContent.js +402 -0
  52. package/package.json +2 -1
  53. package/src/channelCustomer.ts +604 -0
  54. package/src/channelDebounce.ts +17 -0
  55. package/src/chunkOverlap.ts +6 -0
  56. package/src/chunking.ts +25 -4
  57. package/src/citations.ts +127 -0
  58. package/src/constants.ts +112 -10
  59. package/src/index.ts +152 -2
  60. package/src/jsonSchemaToZodShape.ts +31 -13
  61. package/src/pagination.ts +193 -0
  62. package/src/processQueueBatch.ts +42 -12
  63. package/src/queueFailure.ts +63 -0
  64. package/src/resourceRerank.ts +5 -1
  65. package/src/schema.ts +100 -4
  66. package/src/ssrf.ts +40 -11
  67. package/src/toolConfirmation.ts +123 -0
  68. package/src/toolCount.ts +53 -0
  69. package/src/toolLint.ts +46 -3
  70. 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.toLowerCase().replace(/^\[|\]$/g, '');
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 === '::1' || host === '::') return true;
24
- // IPv6 link-local (fe80::/10) and unique-local (fc00::/7).
25
- if (
26
- host.startsWith('fe80:') ||
27
- host.startsWith('fc') ||
28
- host.startsWith('fd')
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 (ipv4InPrivateRange(host)) return true;
33
- return false;
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true;
62
+ return isBlockedHost(parsed.hostname);
34
63
  };
@@ -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)}`;
@@ -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;
@@ -93,9 +95,12 @@ const fold = (text: string): string =>
93
95
  // Wording that tells a model WHEN to call a tool rather than only what it does
94
96
  // — English, Spanish and Portuguese, the languages descriptions here are
95
97
  // written in. Loose on purpose: a false "says when" costs nothing, a false
96
- // "doesn't" nags someone whose description is fine.
98
+ // "doesn't" nags someone whose description is fine. But not so loose that
99
+ // words about how a tool works count: "uses the Stripe API" and "makes an API
100
+ // call" say nothing about when to reach for it, so "call" counts only as an
101
+ // instruction ("call this…", "call it…").
97
102
  const USAGE_GUIDANCE =
98
- /\b(use|uses|using|call|calls|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
103
+ /\b(use|call (?:this|it)|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
99
104
 
100
105
  const STOPWORDS = new Set([
101
106
  'the',
@@ -143,9 +148,22 @@ const contentWords = (text: string): Set<string> =>
143
148
  .filter(word => word.length >= 3 && !STOPWORDS.has(word))
144
149
  );
145
150
 
151
+ // A proxied tool is named `<server prefix>__<remote name>`. The prefix says
152
+ // which connection it came from, not what it does, so `search_repositories`
153
+ // and `github__search_repositories` are the same tool to a model. Kept here
154
+ // rather than imported from constants (MCP_PROXY_TOOL_NAME_SEP), since this
155
+ // file imports nothing.
156
+ const PROXY_NAME_SEP = '__';
157
+
158
+ const withoutProxyPrefix = (name: string): string => {
159
+ const at = name.lastIndexOf(PROXY_NAME_SEP);
160
+ const bare = at === -1 ? name : name.slice(at + PROXY_NAME_SEP.length);
161
+ return bare || name;
162
+ };
163
+
146
164
  // `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
147
165
  const nameTokens = (name: string): string =>
148
- name
166
+ withoutProxyPrefix(name)
149
167
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
150
168
  .toLowerCase()
151
169
  .split(/[^a-z0-9]+/)
@@ -210,6 +228,21 @@ const undescribedPaths = (schema: unknown, prefix = ''): string[] => {
210
228
  return paths;
211
229
  };
212
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
+
213
246
  const quoted = (name: string) => `"${name}"`;
214
247
 
215
248
  /**
@@ -286,6 +319,16 @@ export const lintTools = (
286
319
  });
287
320
  }
288
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
+
289
332
  // Each pair once — against the tools after this one, then the rest of the
290
333
  // server — and one finding per tool, so twenty look-alikes are nineteen
291
334
  // lines rather than a hundred and ninety.