@knitli/openapi-mcp 1.4.0 → 1.4.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.
@@ -90,7 +90,11 @@ export function resolveCanonicalPointer(doc, pointer) {
90
90
  !Object.hasOwn(node, index)) {
91
91
  throw new Error("JSON Pointer array index was not found");
92
92
  }
93
- node = node[index];
93
+ const element = Object.getOwnPropertyDescriptor(node, index);
94
+ if (element === undefined || !("value" in element)) {
95
+ throw new Error("JSON Pointer array index was not found");
96
+ }
97
+ node = element.value;
94
98
  continue;
95
99
  }
96
100
  if (typeof node !== "object" ||
@@ -90,7 +90,8 @@ function requireString(value, label, maximum, pattern) {
90
90
  if (typeof value !== "string" ||
91
91
  value.length === 0 ||
92
92
  value.length > maximum ||
93
- (pattern && !pattern.test(value))) {
93
+ (pattern && !pattern.test(value)) // nosemgrep -- anchored linear-time literal; length capped by maximum above
94
+ ) {
94
95
  throw manifestInvalid(`${label} is invalid`);
95
96
  }
96
97
  return value;
@@ -107,7 +108,10 @@ function requireInteger(value, label) {
107
108
  return value;
108
109
  }
109
110
  function requireDigest(value, label) {
110
- if (typeof value !== "string" || !digestPattern.test(value))
111
+ if (typeof value !== "string" ||
112
+ value.length !== 64 ||
113
+ !digestPattern.test(value) // nosemgrep -- anchored fixed-length linear literal; length pinned to 64 above
114
+ )
111
115
  throw manifestInvalid(`${label} is invalid`);
112
116
  return value;
113
117
  }
@@ -17,7 +17,8 @@ function assertSegment(value, maximumLength, pattern, label) {
17
17
  value.length > maximumLength ||
18
18
  value === "." ||
19
19
  value === ".." ||
20
- !pattern.test(value)) {
20
+ !pattern.test(value) // nosemgrep -- anchored linear-time literal; length capped by maximumLength above
21
+ ) {
21
22
  throw invalidReference(`${label} is invalid`);
22
23
  }
23
24
  }
@@ -530,7 +530,7 @@ function serializeCanonical(value, ancestors) {
530
530
  }
531
531
  function isCanonicalArrayIndex(key, length) {
532
532
  if (!/^(?:0|[1-9]\d*)$/.test(key))
533
- return false;
533
+ return false; // nosemgrep -- anchored literal, disjoint alternation, single star: linear-time
534
534
  const index = Number(key);
535
535
  return (Number.isSafeInteger(index) &&
536
536
  index >= 0 &&
@@ -70,7 +70,7 @@ function requireIdentity(catalogId, issuer) {
70
70
  catalogId.length > 128 ||
71
71
  catalogId === "." ||
72
72
  catalogId === ".." ||
73
- !identityPattern.test(catalogId) ||
73
+ !identityPattern.test(catalogId) || // nosemgrep -- anchored linear-time literal; input length is capped at 128 above
74
74
  typeof issuer !== "string" ||
75
75
  issuer.length === 0 ||
76
76
  issuer.length > 256 ||
@@ -299,16 +299,76 @@ async function validateMutexMetadata(mutex) {
299
299
  }
300
300
  return before;
301
301
  }
302
- function regexEscape(value) {
303
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302
+ const mutexInitSuffix = ".mutex-init";
303
+ const mutexInitUuidSegments = [8, 4, 4, 4, 12];
304
+ function isAsciiHexDigit(code) {
305
+ return ((code >= 0x30 && code <= 0x39) ||
306
+ (code >= 0x41 && code <= 0x46) ||
307
+ (code >= 0x61 && code <= 0x66));
308
+ }
309
+ function isMutexInitializerUuid(candidate) {
310
+ // 8-4-4-4-12 lowercase/uppercase hex with version 1-5 and variant 8/9/a/b.
311
+ if (candidate.length !== 36)
312
+ return false;
313
+ let position = 0;
314
+ for (let segment = 0; segment < mutexInitUuidSegments.length; segment += 1) {
315
+ if (segment > 0) {
316
+ if (candidate[position] !== "-")
317
+ return false;
318
+ position += 1;
319
+ }
320
+ const length = mutexInitUuidSegments[segment];
321
+ for (let index = 0; index < length; index += 1) {
322
+ const code = candidate.charCodeAt(position);
323
+ if (segment === 2 && index === 0) {
324
+ if (code < 0x31 || code > 0x35)
325
+ return false;
326
+ }
327
+ else if (segment === 3 && index === 0) {
328
+ if (code !== 0x38 &&
329
+ code !== 0x39 &&
330
+ code !== 0x41 &&
331
+ code !== 0x42 &&
332
+ code !== 0x61 &&
333
+ code !== 0x62)
334
+ return false;
335
+ }
336
+ else if (!isAsciiHexDigit(code)) {
337
+ return false;
338
+ }
339
+ position += 1;
340
+ }
341
+ }
342
+ return true;
343
+ }
344
+ function isMutexInitializerAlias(name, targetName) {
345
+ // Matches `.<target>.<digits>.<uuid>.mutex-init` case-insensitively,
346
+ // without building a RegExp from the target name.
347
+ const prefix = `.${targetName}.`;
348
+ if (name.length < prefix.length + 1 + 1 + 36 + mutexInitSuffix.length)
349
+ return false;
350
+ if (name.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase())
351
+ return false;
352
+ if (name.slice(-mutexInitSuffix.length).toLowerCase() !== mutexInitSuffix)
353
+ return false;
354
+ const middle = name.slice(prefix.length, -mutexInitSuffix.length);
355
+ const separator = middle.indexOf(".");
356
+ if (separator <= 0)
357
+ return false;
358
+ const counter = middle.slice(0, separator);
359
+ for (let index = 0; index < counter.length; index += 1) {
360
+ const code = counter.charCodeAt(index);
361
+ if (code < 0x30 || code > 0x39)
362
+ return false;
363
+ }
364
+ return isMutexInitializerUuid(middle.slice(separator + 1));
304
365
  }
305
366
  async function clearMutexInitializerAlias(location) {
306
367
  const final = await lstat(location.mutex);
307
- const namePattern = new RegExp(`^\\.${regexEscape(basename(location.target))}\\.\\d+\\.` +
308
- "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.mutex-init$", "i");
368
+ const targetName = basename(location.target);
309
369
  let removed = false;
310
370
  for (const name of await readdir(location.parent)) {
311
- if (!namePattern.test(name))
371
+ if (!isMutexInitializerAlias(name, targetName))
312
372
  continue;
313
373
  const candidate = join(location.parent, name);
314
374
  let metadata;
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import { acceptedContent, inputResponse, } from "@modelcontextprotocol/server";
2
3
  import { z } from "zod";
3
4
  import { snapshotCredentialAuthorizationBinding } from "../runtime/credential-binding.js";
@@ -54,6 +55,11 @@ function hexRandom(randomBytes) {
54
55
  }
55
56
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
56
57
  }
58
+ function randomIdsEqual(left, right) {
59
+ if (left.length !== right.length)
60
+ return false;
61
+ return timingSafeEqual(Buffer.from(left), Buffer.from(right));
62
+ }
57
63
  function exactPayloadMatch(payload, pending) {
58
64
  return (payload.pendingId === pending.pendingId &&
59
65
  payload.callDigest === pending.callDigest &&
@@ -416,7 +422,7 @@ class LedgerActionAuthorizer {
416
422
  const authorizationId = Object.freeze(Object.create(null));
417
423
  let collision = false;
418
424
  for (const existing of this.#receipts.values()) {
419
- if (existing.randomId === token) {
425
+ if (randomIdsEqual(existing.randomId, token)) {
420
426
  collision = true;
421
427
  break;
422
428
  }
@@ -138,12 +138,45 @@ function outcome(value, snapshot) {
138
138
  ]
139
139
  .filter(Boolean)
140
140
  .sort((left, right) => right.length - left.length);
141
- const pattern = variants.length
142
- ? new RegExp(variants
143
- .map((entry) => entry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
144
- .join("|"), "g")
145
- : undefined;
146
- const redact = (text) => pattern ? text.replace(pattern, "[REDACTED]") : text;
141
+ // Literal redaction without a dynamic RegExp: find the earliest occurrence
142
+ // across variants (ties broken longest-first, matching the previous
143
+ // longest-first alternation), redact it, and resume after the match.
144
+ // indexOf skips non-matching regions in native code, so adversarial
145
+ // long-prefix inputs stay linear instead of quadratic per offset.
146
+ const redact = (text) => {
147
+ if (variants.length === 0 || text.length === 0)
148
+ return text;
149
+ const parts = [];
150
+ let cursor = 0;
151
+ let redacted = false;
152
+ for (;;) {
153
+ let at = -1;
154
+ let match = "";
155
+ for (const variant of variants) {
156
+ const found = text.indexOf(variant, cursor);
157
+ if (found === -1)
158
+ continue;
159
+ if (at === -1 ||
160
+ found < at ||
161
+ (found === at && variant.length > match.length)) {
162
+ at = found;
163
+ match = variant;
164
+ }
165
+ }
166
+ if (at === -1)
167
+ break;
168
+ if (at > cursor)
169
+ parts.push(text.slice(cursor, at));
170
+ parts.push("[REDACTED]");
171
+ cursor = at + match.length;
172
+ redacted = true;
173
+ }
174
+ if (!redacted)
175
+ return text;
176
+ if (cursor < text.length)
177
+ parts.push(text.slice(cursor));
178
+ return parts.join("");
179
+ };
147
180
  // Only upstream values are untrusted. Keep protocol fields and opaque tokens intact.
148
181
  if (value.kind === "success") {
149
182
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@knitli/openapi-mcp",
3
3
  "private": false,
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "type": "module",
6
6
  "description": "Compile signed OpenAPI catalogs and serve verified search and authorized API calls over MCP.",
7
7
  "license": "MIT OR Apache-2.0",
@@ -73,7 +73,7 @@
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/server": "2.0.0",
75
75
  "undici": "8.10.2",
76
- "yaml": "^2.9.0",
76
+ "yaml": "2.9.0",
77
77
  "zod": "4.5.4"
78
78
  },
79
79
  "devDependencies": {