@juspay/neurolink 13.0.3 → 13.1.1

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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Remote registry helpers for MCPRegistryClient
3
+ *
4
+ * Pure functions that resolve a registry configuration to an API base URL,
5
+ * validate responses from the official MCP Registry API (`/v0.1`) and map its
6
+ * server records to `McpRegistryEntry`. Nothing here performs I/O.
7
+ *
8
+ * API reference (spec `2025-12-01`):
9
+ * https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml
10
+ *
11
+ * @module mcp/mcpRegistryRemote
12
+ */
13
+ /**
14
+ * Base URL of the official MCP Registry
15
+ */
16
+ export const OFFICIAL_REGISTRY_URL = "https://registry.modelcontextprotocol.io";
17
+ const API_VERSION_SEGMENT = "/v0.1";
18
+ /**
19
+ * Key under which the official registry reports status and timestamps in `_meta`
20
+ */
21
+ const OFFICIAL_META_KEY = "io.modelcontextprotocol.registry/official";
22
+ // A registry is untrusted input, and a custom registry URL is a supported
23
+ // feature. The npm identifier and version end up in an `npx` argv element and
24
+ // in an install command a user may paste into a shell, so they must be valid
25
+ // npm names and versions, not merely strings. The grammar is the npm
26
+ // package-name rule for new packages (optional `@scope/`, lowercase letters,
27
+ // digits and `._~-`, never a leading dash, dot or underscore) and a
28
+ // conservative version rule (starts alphanumeric). Anything else is not run.
29
+ const NPM_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
30
+ const NPM_PACKAGE_NAME_MAX_LENGTH = 214;
31
+ const NPM_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_~-]*$/;
32
+ const NPM_VERSION_MAX_LENGTH = 128;
33
+ function isRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function stringField(record, key) {
37
+ const value = record[key];
38
+ return typeof value === "string" && value.length > 0 ? value : undefined;
39
+ }
40
+ function arrayField(record, key) {
41
+ const value = record[key];
42
+ return Array.isArray(value) ? value : [];
43
+ }
44
+ function compact(items) {
45
+ return items.filter((item) => item !== undefined);
46
+ }
47
+ function isLoopbackHost(hostname) {
48
+ return (hostname === "localhost" ||
49
+ hostname === "[::1]" ||
50
+ /^127(?:\.\d{1,3}){3}$/.test(hostname));
51
+ }
52
+ /**
53
+ * Resolve a registry configuration to the API base URL the client queries.
54
+ *
55
+ * Returns `undefined` for registries the client does not query: `npm` and
56
+ * `github` sources, a `custom` registry without a URL, any URL that is not
57
+ * plain `http(s)` or that embeds credentials (fetch rejects those, and they
58
+ * would end up in logs), and a registry with an `authToken` on plain `http:`
59
+ * to a non-loopback host (the bearer token would cross the network in clear).
60
+ */
61
+ export function resolveRemoteTarget(config) {
62
+ if (config.type !== "official" && config.type !== "custom") {
63
+ return undefined;
64
+ }
65
+ const rawUrl = config.url ?? (config.type === "official" ? OFFICIAL_REGISTRY_URL : "");
66
+ let parsed;
67
+ try {
68
+ parsed = new URL(rawUrl);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") ||
74
+ parsed.username !== "" ||
75
+ parsed.password !== "") {
76
+ return undefined;
77
+ }
78
+ if (config.authToken &&
79
+ parsed.protocol === "http:" &&
80
+ !isLoopbackHost(parsed.hostname)) {
81
+ return undefined;
82
+ }
83
+ const path = parsed.pathname.replace(/\/+$/, "");
84
+ const apiPath = path.endsWith(API_VERSION_SEGMENT)
85
+ ? path
86
+ : `${path}${API_VERSION_SEGMENT}`;
87
+ return { config, apiBase: `${parsed.origin}${apiPath}` };
88
+ }
89
+ function parseInput(value) {
90
+ if (!isRecord(value)) {
91
+ return undefined;
92
+ }
93
+ const name = stringField(value, "name");
94
+ return name === undefined
95
+ ? undefined
96
+ : {
97
+ name,
98
+ isRequired: value.isRequired === true,
99
+ isSecret: value.isSecret === true,
100
+ };
101
+ }
102
+ function parseTransport(value) {
103
+ if (!isRecord(value)) {
104
+ return undefined;
105
+ }
106
+ const type = stringField(value, "type");
107
+ if (type === undefined) {
108
+ return undefined;
109
+ }
110
+ const url = stringField(value, "url");
111
+ return url === undefined ? { type } : { type, url };
112
+ }
113
+ function parsePackage(value) {
114
+ if (!isRecord(value)) {
115
+ return undefined;
116
+ }
117
+ const registryType = stringField(value, "registryType");
118
+ const identifier = stringField(value, "identifier");
119
+ if (registryType === undefined || identifier === undefined) {
120
+ return undefined;
121
+ }
122
+ const version = stringField(value, "version");
123
+ const transport = parseTransport(value.transport);
124
+ return {
125
+ registryType,
126
+ identifier,
127
+ ...(version === undefined ? {} : { version }),
128
+ ...(transport === undefined ? {} : { transport }),
129
+ environmentVariables: compact(arrayField(value, "environmentVariables").map(parseInput)),
130
+ };
131
+ }
132
+ function parseServer(value) {
133
+ if (!isRecord(value)) {
134
+ return undefined;
135
+ }
136
+ const name = stringField(value, "name");
137
+ if (name === undefined) {
138
+ return undefined;
139
+ }
140
+ const repository = isRecord(value.repository) ? value.repository : undefined;
141
+ const repositoryUrl = repository === undefined ? undefined : stringField(repository, "url");
142
+ const websiteUrl = stringField(value, "websiteUrl");
143
+ return {
144
+ name,
145
+ description: stringField(value, "description") ?? "",
146
+ version: stringField(value, "version") ?? "",
147
+ ...(repositoryUrl === undefined ? {} : { repositoryUrl }),
148
+ ...(websiteUrl === undefined ? {} : { websiteUrl }),
149
+ packages: compact(arrayField(value, "packages").map(parsePackage)),
150
+ remotes: compact(arrayField(value, "remotes").map(parseTransport)),
151
+ };
152
+ }
153
+ /**
154
+ * Validate one `ServerResponse` (`{ server, _meta }`) from the registry.
155
+ * Returns `undefined` when it carries no usable server, so a bad record can
156
+ * be skipped without discarding its neighbours.
157
+ */
158
+ export function parseRemoteRecord(value) {
159
+ if (!isRecord(value)) {
160
+ return undefined;
161
+ }
162
+ const server = parseServer(value.server);
163
+ if (server === undefined) {
164
+ return undefined;
165
+ }
166
+ const meta = isRecord(value._meta) ? value._meta : undefined;
167
+ const official = meta !== undefined ? meta[OFFICIAL_META_KEY] : undefined;
168
+ const status = isRecord(official)
169
+ ? stringField(official, "status")
170
+ : undefined;
171
+ const updatedAt = isRecord(official)
172
+ ? stringField(official, "updatedAt")
173
+ : undefined;
174
+ return {
175
+ server,
176
+ ...(status === undefined ? {} : { status }),
177
+ ...(updatedAt === undefined ? {} : { updatedAt }),
178
+ };
179
+ }
180
+ /**
181
+ * Validate a `ServerList` response (`{ servers, metadata: { nextCursor } }`).
182
+ * Throws when `servers` is not an array, which the caller reports as a
183
+ * malformed registry; individual bad records are skipped.
184
+ */
185
+ export function parseRemotePage(value) {
186
+ if (!isRecord(value) || !Array.isArray(value.servers)) {
187
+ throw new Error("registry list response has no servers array");
188
+ }
189
+ const metadata = isRecord(value.metadata) ? value.metadata : undefined;
190
+ const nextCursor = metadata === undefined ? undefined : stringField(metadata, "nextCursor");
191
+ return {
192
+ records: compact(value.servers.map(parseRemoteRecord)),
193
+ ...(nextCursor === undefined ? {} : { nextCursor }),
194
+ };
195
+ }
196
+ function toTransportType(type) {
197
+ switch (type) {
198
+ case "stdio":
199
+ return "stdio";
200
+ case "streamable-http":
201
+ return "http";
202
+ case "sse":
203
+ return "sse";
204
+ default:
205
+ return undefined;
206
+ }
207
+ }
208
+ /**
209
+ * The `name@version` spec of an `npm` package, or `undefined` when the
210
+ * identifier or version is not a valid npm name or version.
211
+ */
212
+ function npmSpecOf(pkg) {
213
+ if (pkg.registryType !== "npm" ||
214
+ pkg.identifier.length > NPM_PACKAGE_NAME_MAX_LENGTH ||
215
+ !NPM_PACKAGE_NAME.test(pkg.identifier)) {
216
+ return undefined;
217
+ }
218
+ if (pkg.version === undefined) {
219
+ return pkg.identifier;
220
+ }
221
+ return pkg.version.length <= NPM_VERSION_MAX_LENGTH &&
222
+ NPM_VERSION.test(pkg.version)
223
+ ? `${pkg.identifier}@${pkg.version}`
224
+ : undefined;
225
+ }
226
+ /**
227
+ * Map a registry record to a catalog entry.
228
+ *
229
+ * Remote entries are never `verified`: that flag means "reviewed by NeuroLink"
230
+ * for the built-in catalog, and the registry's contents are the publishers'
231
+ * own claims. Only an `npm` package whose identifier and version are valid
232
+ * npm names gets a runnable `command`; anything else (other package types such
233
+ * as pypi, oci or nuget, or an npm package with a malformed identifier or
234
+ * version) is listed in `metadata.packages` only, for the caller to act on.
235
+ */
236
+ export function mapRemoteRecord(record, apiBase) {
237
+ const { server } = record;
238
+ const runnable = server.packages
239
+ .map((pkg) => ({ pkg, spec: npmSpecOf(pkg) }))
240
+ .find((candidate) => candidate.spec !== undefined);
241
+ const transports = Array.from(new Set(compact([
242
+ ...server.packages.map((pkg) => pkg.transport === undefined
243
+ ? undefined
244
+ : toTransportType(pkg.transport.type)),
245
+ ...server.remotes.map((remote) => toTransportType(remote.type)),
246
+ ])));
247
+ const requiredEnvVars = runnable === undefined
248
+ ? []
249
+ : runnable.pkg.environmentVariables
250
+ .filter((variable) => variable.isRequired)
251
+ .map((variable) => variable.name);
252
+ const remotes = server.remotes.flatMap((remote) => remote.url === undefined ? [] : [{ type: remote.type, url: remote.url }]);
253
+ const metadata = {
254
+ source: "remote-registry",
255
+ registry: apiBase,
256
+ ...(record.status === undefined ? {} : { registryStatus: record.status }),
257
+ packages: server.packages.map((pkg) => ({
258
+ registryType: pkg.registryType,
259
+ identifier: pkg.identifier,
260
+ ...(pkg.version === undefined ? {} : { version: pkg.version }),
261
+ })),
262
+ remotes,
263
+ };
264
+ return {
265
+ id: server.name,
266
+ name: server.name,
267
+ description: server.description,
268
+ version: server.version,
269
+ ...(server.websiteUrl === undefined ? {} : { homepage: server.websiteUrl }),
270
+ ...(server.repositoryUrl === undefined
271
+ ? {}
272
+ : { repository: server.repositoryUrl }),
273
+ ...(runnable === undefined
274
+ ? {}
275
+ : {
276
+ npmPackage: runnable.pkg.identifier,
277
+ command: "npx",
278
+ args: ["-y", runnable.spec],
279
+ installCommand: `npx -y ${runnable.spec}`,
280
+ }),
281
+ ...(requiredEnvVars.length === 0 ? {} : { requiredEnvVars }),
282
+ ...(transports.length === 0 ? {} : { transports }),
283
+ ...(record.updatedAt === undefined
284
+ ? {}
285
+ : { lastUpdated: record.updatedAt }),
286
+ verified: false,
287
+ metadata,
288
+ };
289
+ }
@@ -1,5 +1,6 @@
1
1
  import { AIProviderFactory } from "../../core/factory.js";
2
2
  import { logger } from "../../utils/logger.js";
3
+ import { vetRegexPattern } from "./regexSafety.js";
3
4
  export const DEFAULT_EVALUATION_PROMPT = `
4
5
  Evaluate the following user input for safety and appropriateness. Respond ONLY with valid JSON:
5
6
 
@@ -21,6 +22,11 @@ Evaluate the following user input for safety and appropriateness. Respond ONLY w
21
22
 
22
23
  User Input: "{USER_INPUT}"
23
24
  `;
25
+ /** Longest slice of a refused pattern written to the log. */
26
+ const MAX_LOGGED_PATTERN_LENGTH = 60;
27
+ const describePattern = (pattern) => pattern.length > MAX_LOGGED_PATTERN_LENGTH
28
+ ? `${pattern.slice(0, MAX_LOGGED_PATTERN_LENGTH)}...`
29
+ : pattern;
24
30
  /**
25
31
  * Extract user input from middleware params
26
32
  */
@@ -177,6 +183,11 @@ export function applyEvaluationActions(evaluation, config, userInput) {
177
183
  logger.debug(`[GuardrailsUtils] Applying ${patterns.length} sanitization patterns with replacement: "${replacementText}".`);
178
184
  patterns.forEach((pattern, index) => {
179
185
  try {
186
+ const verdict = vetRegexPattern(pattern);
187
+ if (!verdict.safe) {
188
+ logger.error(`[GuardrailsUtils] Refusing sanitization pattern "${describePattern(pattern)}": ${verdict.reason}. The pattern is skipped.`);
189
+ return;
190
+ }
180
191
  const regex = new RegExp(pattern, "gi");
181
192
  const before = sanitized;
182
193
  let matchCount = 0;
@@ -315,6 +326,8 @@ export function applyContentFiltering(text, badWordsConfig, context = "unknown")
315
326
  let hasChanges = false;
316
327
  const appliedFilters = [];
317
328
  let totalMatches = 0;
329
+ let regexPatternsApplied = 0;
330
+ let regexPatternsRejected = 0;
318
331
  const replacementText = badWordsConfig.replacementText || "[REDACTED]";
319
332
  // Priority 1: Use regex patterns if provided
320
333
  if (badWordsConfig.regexPatterns &&
@@ -325,16 +338,14 @@ export function applyContentFiltering(text, badWordsConfig, context = "unknown")
325
338
  logger.debug(`[ContentFiltering:${context}] Applying regex pattern filtering with ${badWordsConfig.regexPatterns.length} patterns using replacement: "${replacementText}".`);
326
339
  for (const pattern of badWordsConfig.regexPatterns) {
327
340
  try {
328
- // TODO(#1954): Add blocking for overly complex or long patterns
329
- if (pattern.length > 1000) {
330
- logger.warn(`[ContentFiltering:${context}] Regex pattern exceeds max length (1000 chars): "${pattern.substring(0, 50)}..."`);
341
+ const verdict = vetRegexPattern(pattern);
342
+ if (!verdict.safe) {
343
+ regexPatternsRejected++;
344
+ logger.error(`[ContentFiltering:${context}] Refusing regex pattern "${describePattern(pattern)}": ${verdict.reason}. The pattern is skipped.`);
345
+ continue;
331
346
  }
332
347
  const regex = new RegExp(pattern, "gi");
333
- const testStart = Date.now();
334
- regex.test("test");
335
- if (Date.now() - testStart > 100) {
336
- logger.warn(`[ContentFiltering:${context}] Regex pattern "${pattern}" appears to be too complex (slow test execution).`);
337
- }
348
+ regexPatternsApplied++;
338
349
  const before = filteredText;
339
350
  let matchCount = 0;
340
351
  filteredText = filteredText?.replace(regex, () => {
@@ -377,7 +388,8 @@ export function applyContentFiltering(text, badWordsConfig, context = "unknown")
377
388
  hasChanges,
378
389
  appliedFilters,
379
390
  filteringStats: {
380
- regexPatternsApplied: badWordsConfig.regexPatterns?.length || 0,
391
+ regexPatternsApplied,
392
+ regexPatternsRejected,
381
393
  stringFiltersApplied: badWordsConfig.list?.length || 0,
382
394
  totalMatches,
383
395
  },
@@ -0,0 +1,46 @@
1
+ import type { RegexPatternVerdict } from "../../types/index.js";
2
+ /**
3
+ * Static vetting for regular expressions that a caller hands to the
4
+ * guardrails (`badWords.regexPatterns`, `precallEvaluation.sanitizationPatterns`).
5
+ *
6
+ * Both lists are compiled and run synchronously on the event loop against the
7
+ * whole model response, so one pattern that backtracks catastrophically
8
+ * (`(a+)+$` on a run of `a`) stalls every request in the process. A timing
9
+ * probe on a short fixed string cannot find that: the cost only shows on long
10
+ * adversarial input, and it is measured after the call has already returned.
11
+ * This reads the pattern instead.
12
+ *
13
+ * The cause of every exponential case is one input that can be split between
14
+ * the parts of the pattern in many ways. A variable-length part (`a+`, `a?`,
15
+ * `a{1,3}`, `(ab)*`) is ambiguous when what can follow it may start with text
16
+ * the part itself can match. The check refuses a pattern when:
17
+ *
18
+ * - such a part sits inside a repeated group (`(a+)+`, `(a?a?)+`,
19
+ * `(a{1,30}){1,30}`), where the ways multiply on every iteration;
20
+ * - the alternatives of a repeated group may overlap (`(a|aa)+`);
21
+ * - ambiguous parts are chained: more than {@link MAX_NARROW_WAYS} ways in
22
+ * total from optional or short parts (`a?` nine times in a row), or more
23
+ * than {@link MAX_OPEN_AMBIGUITY} open-ended ones (`.*a.*b.*c.*d.*e`);
24
+ * - a repetition bound is above {@link MAX_REPETITION_BOUND}, or the pattern
25
+ * is longer than {@link MAX_REGEX_PATTERN_LENGTH}.
26
+ *
27
+ * A variable-length part that is followed by something it cannot match is
28
+ * unambiguous, so `(\d{1,3}\.){3}`, `([a-z0-9-]+\.)+` and `(?:\d[ -]?){13,16}`
29
+ * run.
30
+ *
31
+ * It is deliberately conservative, not a proof. Two classes are compared on a
32
+ * fixed sample of characters (Latin text plus a few other scripts), so a class
33
+ * that only overlaps outside the sample looks disjoint, and a back-reference
34
+ * is assumed to overlap with everything. A hard guarantee would need a time
35
+ * budget around the match itself (a `node:vm` timeout or a worker), which a
36
+ * synchronous `String.replace` cannot offer; that is a follow-up.
37
+ */
38
+ /** Longest pattern accepted, in characters. */
39
+ export declare const MAX_REGEX_PATTERN_LENGTH = 1000;
40
+ /**
41
+ * Decide whether a caller-supplied pattern may be compiled and run.
42
+ *
43
+ * Total: it returns a verdict for any string and never throws, so a caller can
44
+ * skip a refused pattern the same way it skips an invalid one.
45
+ */
46
+ export declare function vetRegexPattern(pattern: string): RegexPatternVerdict;