@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.
- 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 +108 -10
- 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 +37 -3
- 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 +112 -10
- 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 +46 -3
- 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;
|
|
@@ -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|
|
|
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.
|