@procedures/client 0.27.8 → 0.28.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  4. package/dist/adapters/websocket/shared/rpc.js +5 -2
  5. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  6. package/dist/client/batch-executor.d.ts.map +1 -1
  7. package/dist/client/batch-executor.js +25 -14
  8. package/dist/client/batch-executor.js.map +1 -1
  9. package/dist/client/middleware/cache.d.ts.map +1 -1
  10. package/dist/client/middleware/cache.js +27 -1
  11. package/dist/client/middleware/cache.js.map +1 -1
  12. package/dist/procedures/collection/query.d.ts.map +1 -1
  13. package/dist/procedures/collection/query.js +7 -52
  14. package/dist/procedures/collection/query.js.map +1 -1
  15. package/dist/procedures/collection/regex-guard.d.ts +18 -0
  16. package/dist/procedures/collection/regex-guard.d.ts.map +1 -0
  17. package/dist/procedures/collection/regex-guard.js +342 -0
  18. package/dist/procedures/collection/regex-guard.js.map +1 -0
  19. package/dist/procedures/core/math.d.ts.map +1 -1
  20. package/dist/procedures/core/math.js +9 -2
  21. package/dist/procedures/core/math.js.map +1 -1
  22. package/dist/procedures/define-procedure.d.ts +3 -1
  23. package/dist/procedures/define-procedure.d.ts.map +1 -1
  24. package/dist/procedures/define-procedure.js +12 -1
  25. package/dist/procedures/define-procedure.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/adapters/websocket/shared/rpc.ts +4 -2
  28. package/src/client/batch-executor.ts +24 -14
  29. package/src/client/middleware/cache.ts +30 -1
  30. package/src/procedures/collection/query.ts +7 -48
  31. package/src/procedures/collection/regex-guard.ts +338 -0
  32. package/src/procedures/core/math.ts +7 -2
  33. package/src/procedures/define-procedure.ts +16 -1
@@ -0,0 +1,338 @@
1
+ /**
2
+ * The guard of `$regex` against patterns that can take an exponential time in a backtracking
3
+ * engine such as V8. It reads the pattern, and it refuses a group that repeats when the group can
4
+ * match the same text in more than one way:
5
+ *
6
+ * - The group holds a repetition of variable length: `(a+)+`, `(a{2,3})+`, `(a?a?)+`.
7
+ * - The group holds alternatives that can start with the same character: `(a|a)*`, `(a|aa)+`.
8
+ *
9
+ * A group with a bounded repetition passes when the number of its ways to match is small, for
10
+ * example `(\d{1,3}\.){3}`. The guard does not find each polynomial pattern, for example `\s*\s*$`.
11
+ * A host keeps `maxRegexLength` small for callers that it does not trust.
12
+ */
13
+
14
+ /** A set of UTF-16 code units: the ranges, or each code unit outside them when `negated`. */
15
+ interface CharSet {
16
+ readonly ranges: ReadonlyArray<readonly [number, number]>;
17
+ readonly negated: boolean;
18
+ }
19
+
20
+ /** One part of a pattern. */
21
+ type RegexNode =
22
+ | { readonly kind: "atom"; readonly first: CharSet; readonly width: boolean }
23
+ | { readonly kind: "group"; readonly branches: RegexNode[][]; readonly width: boolean }
24
+ | { readonly kind: "repeat"; readonly node: RegexNode; readonly min: number; readonly max: number };
25
+
26
+ /** The most ways to match that a group with a bounded repetition can have. */
27
+ const MAX_WAYS = 1000;
28
+
29
+ const ANY: CharSet = { ranges: [], negated: true };
30
+ const DIGIT: Array<[number, number]> = [[0x30, 0x39]];
31
+ const WORD: Array<[number, number]> = [[0x30, 0x39], [0x41, 0x5a], [0x5f, 0x5f], [0x61, 0x7a]];
32
+ const SPACE: Array<[number, number]> = [
33
+ [0x09, 0x0d], [0x20, 0x20], [0xa0, 0xa0], [0x1680, 0x1680], [0x2000, 0x200a],
34
+ [0x2028, 0x2029], [0x202f, 0x202f], [0x205f, 0x205f], [0x3000, 0x3000], [0xfeff, 0xfeff],
35
+ ];
36
+ const CONTROL_ESCAPES: Record<string, number> = { t: 9, n: 10, v: 11, f: 12, r: 13, "0": 0 };
37
+
38
+ /** With the flag `i`, each ASCII letter of a range also gives the letter of the other case. */
39
+ function folded(ranges: ReadonlyArray<readonly [number, number]>, ignoreCase: boolean): Array<readonly [number, number]> {
40
+ const result: Array<readonly [number, number]> = [...ranges];
41
+ if (!ignoreCase) return result;
42
+ for (const [lo, hi] of ranges) {
43
+ for (const [from, to, shift] of [[0x41, 0x5a, 0x20], [0x61, 0x7a, -0x20]] as const) {
44
+ const start = Math.max(lo, from);
45
+ const end = Math.min(hi, to);
46
+ if (start <= end) result.push([start + shift, end + shift]);
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+
52
+ /** True when each code unit of a range is in one of the ranges. */
53
+ function covered([lo, hi]: readonly [number, number], ranges: ReadonlyArray<readonly [number, number]>): boolean {
54
+ let next = lo;
55
+ for (const [from, to] of [...ranges].sort((a, b) => a[0] - b[0])) {
56
+ if (from > next) break;
57
+ next = Math.max(next, to + 1);
58
+ if (next > hi) return true;
59
+ }
60
+ return next > hi;
61
+ }
62
+
63
+ /** True when two sets can have the same code unit. */
64
+ function overlaps(a: CharSet, b: CharSet): boolean {
65
+ if (a.negated && b.negated) return true;
66
+ if (!a.negated && !b.negated) return a.ranges.some(([lo, hi]) => b.ranges.some(([from, to]) => lo <= to && from <= hi));
67
+ const [positive, negative] = a.negated ? [b, a] : [a, b];
68
+ return positive.ranges.some((range) => !covered(range, negative.ranges));
69
+ }
70
+
71
+ /** This function reads a pattern into parts. A construct that it does not know becomes an atom that can match each character. */
72
+ function parse(pattern: string, ignoreCase: boolean): RegexNode[][] {
73
+ let at = 0;
74
+ const set = (ranges: ReadonlyArray<readonly [number, number]>, negated = false): CharSet => ({ ranges: folded(ranges, ignoreCase), negated });
75
+ const unit = (code: number): CharSet => set([[code, code]]);
76
+
77
+ /** The code unit of an escape that names one character, after the backslash. */
78
+ const escapedCode = (): number => {
79
+ const char = pattern[at++] ?? "\\";
80
+ if (char in CONTROL_ESCAPES) return CONTROL_ESCAPES[char]!;
81
+ if (char === "x" && /^[0-9a-fA-F]{2}/.test(pattern.slice(at))) {
82
+ at += 2;
83
+ return Number.parseInt(pattern.slice(at - 2, at), 16);
84
+ }
85
+ if (char === "u") {
86
+ const braced = /^\{([0-9a-fA-F]+)\}/.exec(pattern.slice(at));
87
+ if (braced) {
88
+ at += braced[0].length;
89
+ const code = Number.parseInt(braced[1]!, 16);
90
+ return code > 0xffff ? 0xd800 + ((code - 0x10000) >> 10) : code;
91
+ }
92
+ if (/^[0-9a-fA-F]{4}/.test(pattern.slice(at))) {
93
+ at += 4;
94
+ return Number.parseInt(pattern.slice(at - 4, at), 16);
95
+ }
96
+ }
97
+ if (char === "c" && /^[a-zA-Z]/.test(pattern.slice(at))) return pattern.charCodeAt(at++) % 32;
98
+ return char.charCodeAt(0);
99
+ };
100
+
101
+ /** The set of a class escape (`\d`, `\W`), or undefined for another escape. The backslash is read. */
102
+ const classEscape = (): CharSet | undefined => {
103
+ const char = pattern[at];
104
+ const ranges = char === "d" || char === "D" ? DIGIT : char === "w" || char === "W" ? WORD : char === "s" || char === "S" ? SPACE : undefined;
105
+ if (ranges !== undefined) {
106
+ at++;
107
+ return set(ranges, char === "D" || char === "W" || char === "S");
108
+ }
109
+ if (char === "p" || char === "P") {
110
+ at++;
111
+ if (pattern[at] === "{") {
112
+ const end = pattern.indexOf("}", at);
113
+ at = end === -1 ? pattern.length : end + 1;
114
+ }
115
+ return ANY;
116
+ }
117
+ return undefined;
118
+ };
119
+
120
+ const parseClass = (): CharSet => {
121
+ const negated = pattern[at] === "^";
122
+ if (negated) at++;
123
+ const ranges: Array<[number, number]> = [];
124
+ let unknown = false;
125
+ const member = (): number | undefined => {
126
+ if (pattern[at] !== "\\") return pattern.charCodeAt(at++);
127
+ at++;
128
+ const escape = classEscape();
129
+ if (escape !== undefined) {
130
+ // A negated escape in a class, or a property: the set is not known here
131
+ if (escape.negated) unknown = true;
132
+ else ranges.push(...(escape.ranges as Array<[number, number]>));
133
+ return undefined;
134
+ }
135
+ if (pattern[at] === "b") {
136
+ at++;
137
+ return 8;
138
+ }
139
+ return escapedCode();
140
+ };
141
+ while (at < pattern.length && pattern[at] !== "]") {
142
+ const low = member();
143
+ if (low === undefined) continue;
144
+ if (pattern[at] === "-" && pattern[at + 1] !== "]" && at + 1 < pattern.length) {
145
+ at++;
146
+ const high = member();
147
+ if (high === undefined) {
148
+ ranges.push([low, low], [0x2d, 0x2d]);
149
+ continue;
150
+ }
151
+ ranges.push([Math.min(low, high), Math.max(low, high)]);
152
+ } else {
153
+ ranges.push([low, low]);
154
+ }
155
+ }
156
+ at++;
157
+ if (unknown) return ANY;
158
+ return set(ranges, negated);
159
+ };
160
+
161
+ const parseAtom = (): RegexNode => {
162
+ const char = pattern[at++]!;
163
+ if (char === "(") {
164
+ let width = true;
165
+ for (const [prefix, zeroWidth] of [["?:", false], ["?=", true], ["?!", true], ["?<=", true], ["?<!", true]] as const) {
166
+ if (pattern.startsWith(prefix, at)) {
167
+ at += prefix.length;
168
+ width = !zeroWidth;
169
+ break;
170
+ }
171
+ }
172
+ if (width && pattern.startsWith("?<", at)) {
173
+ const end = pattern.indexOf(">", at);
174
+ at = end === -1 ? pattern.length : end + 1;
175
+ } else if (width && pattern[at] === "?") {
176
+ // A modifier group, for example (?i:x)
177
+ const colon = pattern.indexOf(":", at);
178
+ at = colon === -1 ? pattern.length : colon + 1;
179
+ }
180
+ const branches = parseAlternatives();
181
+ if (pattern[at] === ")") at++;
182
+ return { kind: "group", branches, width };
183
+ }
184
+ if (char === "[") return { kind: "atom", first: parseClass(), width: true };
185
+ if (char === ".") return { kind: "atom", first: ANY, width: true };
186
+ if (char === "^" || char === "$") return { kind: "atom", first: ANY, width: false };
187
+ if (char === "\\") {
188
+ const escape = classEscape();
189
+ if (escape !== undefined) return { kind: "atom", first: escape, width: true };
190
+ const next = pattern[at];
191
+ if (next === "b" || next === "B") {
192
+ at++;
193
+ return { kind: "atom", first: ANY, width: false };
194
+ }
195
+ // A back reference matches a text that the guard does not know
196
+ if (next !== undefined && /[1-9]/.test(next)) {
197
+ while (/[0-9]/.test(pattern[at] ?? "")) at++;
198
+ return { kind: "atom", first: ANY, width: true };
199
+ }
200
+ if (next === "k" && pattern[at + 1] === "<") {
201
+ const end = pattern.indexOf(">", at);
202
+ at = end === -1 ? pattern.length : end + 1;
203
+ return { kind: "atom", first: ANY, width: true };
204
+ }
205
+ return { kind: "atom", first: unit(escapedCode()), width: true };
206
+ }
207
+ return { kind: "atom", first: unit(char.charCodeAt(0)), width: true };
208
+ };
209
+
210
+ /** The bounds of a quantifier at the position, or undefined. A lazy quantifier has the same bounds. */
211
+ const parseQuantifier = (): { min: number; max: number } | undefined => {
212
+ const char = pattern[at];
213
+ let bounds: { min: number; max: number } | undefined;
214
+ if (char === "*") bounds = { min: 0, max: Infinity };
215
+ else if (char === "+") bounds = { min: 1, max: Infinity };
216
+ else if (char === "?") bounds = { min: 0, max: 1 };
217
+ if (bounds !== undefined) {
218
+ at++;
219
+ } else if (char === "{") {
220
+ const counted = /^\{(\d+)(,(\d*))?\}/.exec(pattern.slice(at));
221
+ if (counted === null) return undefined;
222
+ at += counted[0].length;
223
+ const min = Number(counted[1]);
224
+ bounds = { min, max: counted[2] === undefined ? min : counted[3] === "" ? Infinity : Number(counted[3]) };
225
+ } else {
226
+ return undefined;
227
+ }
228
+ if (pattern[at] === "?") at++;
229
+ return bounds;
230
+ };
231
+
232
+ const parseAlternatives = (): RegexNode[][] => {
233
+ const branches: RegexNode[][] = [[]];
234
+ while (at < pattern.length && pattern[at] !== ")") {
235
+ if (pattern[at] === "|") {
236
+ at++;
237
+ branches.push([]);
238
+ continue;
239
+ }
240
+ const atom = parseAtom();
241
+ const bounds = parseQuantifier();
242
+ branches[branches.length - 1]!.push(bounds === undefined ? atom : { kind: "repeat", node: atom, ...bounds });
243
+ }
244
+ return branches;
245
+ };
246
+
247
+ const top: RegexNode[][] = [];
248
+ while (at < pattern.length) {
249
+ top.push(...parseAlternatives());
250
+ // A ")" without its "(": the RegExp constructor refuses the pattern
251
+ at++;
252
+ }
253
+ return top;
254
+ }
255
+
256
+ /** The characters that a branch can start with. An empty branch, or a start that can be empty, can start with each character. */
257
+ function firstOf(branch: readonly RegexNode[]): CharSet {
258
+ const node = branch[0];
259
+ if (node === undefined) return ANY;
260
+ if (node.kind === "atom") return node.width ? node.first : ANY;
261
+ if (node.kind === "repeat") return node.min >= 1 ? firstOf([node.node]) : ANY;
262
+ if (!node.width) return ANY;
263
+ const sets = node.branches.map(firstOf);
264
+ if (sets.some((one) => one.negated)) return ANY;
265
+ return { ranges: sets.flatMap((one) => one.ranges), negated: false };
266
+ }
267
+
268
+ /** What makes a group match the same text in more than one way. */
269
+ interface Ways {
270
+ /** A repetition without a bound inside the group. */
271
+ unbounded: boolean;
272
+ /** A repetition of variable length inside the group. */
273
+ variable: boolean;
274
+ /** Alternatives inside the group that can start with the same character. */
275
+ overlap: boolean;
276
+ /** An upper bound of the ways to match one time. */
277
+ count: number;
278
+ }
279
+
280
+ /** This function adds the ways of the parts inside a group to `ways`. */
281
+ function collect(nodes: readonly RegexNode[], ways: Ways): void {
282
+ for (const node of nodes) {
283
+ if (node.kind === "repeat") {
284
+ if (node.min !== node.max) {
285
+ ways.variable = true;
286
+ if (node.max === Infinity) ways.unbounded = true;
287
+ }
288
+ // Each repetition multiplies the ways of the part inside it: ((a|a){9}){9} has 2^81 ways, not 2^9
289
+ const inner: Ways = { unbounded: false, variable: false, overlap: false, count: 1 };
290
+ collect([node.node], inner);
291
+ ways.unbounded ||= inner.unbounded;
292
+ ways.variable ||= inner.variable;
293
+ ways.overlap ||= inner.overlap;
294
+ ways.count *= node.max === Infinity ? Infinity : (node.max - node.min + 1) * inner.count ** node.max;
295
+ } else if (node.kind === "group") {
296
+ if (node.branches.length > 1) {
297
+ ways.count *= node.branches.length;
298
+ const firsts = node.branches.map(firstOf);
299
+ if (firsts.some((first, index) => firsts.slice(index + 1).some((other) => overlaps(first, other)))) ways.overlap = true;
300
+ }
301
+ for (const branch of node.branches) collect(branch, ways);
302
+ }
303
+ }
304
+ }
305
+
306
+ /** The first problem of a repeated group in the parts, or undefined. */
307
+ function problemOf(nodes: readonly RegexNode[]): string | undefined {
308
+ for (const node of nodes) {
309
+ if (node.kind === "group") {
310
+ for (const branch of node.branches) {
311
+ const problem = problemOf(branch);
312
+ if (problem !== undefined) return problem;
313
+ }
314
+ continue;
315
+ }
316
+ if (node.kind !== "repeat") continue;
317
+ if (node.max > 1 && node.node.kind === "group") {
318
+ const ways: Ways = { unbounded: false, variable: false, overlap: false, count: 1 };
319
+ collect([node.node], ways);
320
+ if (ways.unbounded) return "repeats a group that repeats without a bound";
321
+ // A bounded repetition with few ways to match passes, for example (\d{1,3}\.){3}
322
+ const few = node.max !== Infinity && ways.count ** node.max <= MAX_WAYS;
323
+ if (ways.overlap && !few) return "repeats a group with alternatives that can start with the same character";
324
+ if (ways.variable && !few) return "repeats a group with a repetition of variable length";
325
+ }
326
+ const problem = problemOf([node.node]);
327
+ if (problem !== undefined) return problem;
328
+ }
329
+ return undefined;
330
+ }
331
+
332
+ /**
333
+ * This function gives the reason why a pattern can take an exponential time, or undefined. It
334
+ * reads the pattern with its flags: with `i`, `a` and `A` are the same character.
335
+ */
336
+ export function exponentialRegex(pattern: string, flags = ""): string | undefined {
337
+ return problemOf(parse(pattern, flags.includes("i")).flat());
338
+ }
@@ -216,7 +216,10 @@ const minProcedure: MinProcedure = defineProcedure({
216
216
  if (input.values.length === 0) {
217
217
  return Infinity;
218
218
  }
219
- return Math.min(...input.values);
219
+ // A loop, not a spread: a long array as arguments overflows the stack (audit pass 2)
220
+ let min = Infinity;
221
+ for (const value of input.values) min = Math.min(min, value);
222
+ return min;
220
223
  },
221
224
  });
222
225
 
@@ -246,7 +249,9 @@ const maxProcedure: MaxProcedure = defineProcedure({
246
249
  if (input.values.length === 0) {
247
250
  return -Infinity;
248
251
  }
249
- return Math.max(...input.values);
252
+ let max = -Infinity;
253
+ for (const value of input.values) max = Math.max(max, value);
254
+ return max;
250
255
  },
251
256
  });
252
257
 
@@ -51,6 +51,7 @@ import { INFERRED_EFFECTS_METADATA_KEY, PURE_EFFECTS, REGISTRY_READ_EFFECTS, eff
51
51
  import { MANIFEST_FORMAT, describeProcedure as describeEntry, describeRegistry, type ProcedureManifest } from "./manifest.js";
52
52
  import type { JsonSchema } from "./schema.js";
53
53
  import { isRuntimeDefined, markRuntimeProgram, runtimeProgramOfHandler } from "./runtime-programs.js";
54
+ import { InvocationError } from "./invoke.js";
54
55
 
55
56
  // Input schemas of procedure.define and procedure.delete (roadmap 2.1)
56
57
  const procedurePathField = z.array(z.string().min(1)).min(1);
@@ -297,6 +298,7 @@ interface DefineMetadata extends ProcedureMetadata {
297
298
  * stays: an MCP client cannot replace a tool or an internal procedure.
298
299
  * - The registry gets the procedure first. A define that fails leaves nothing.
299
300
  * - The new procedure is data-driven: with a server's `expose` rule, it calls only exposed paths.
301
+ * - With a server's `expose` rule, a caller cannot replace a hidden procedure (`NOT_EXPOSED`).
300
302
  * - Its input is raw (the `runs-refs` tag): `exec()` does not run the body at definition time.
301
303
  */
302
304
  export const defineProcedureProcedure: Procedure<
@@ -326,6 +328,14 @@ export const defineProcedureProcedure: Procedure<
326
328
 
327
329
  const pathKey = path.join(".");
328
330
  const existing = registry.get(path);
331
+ // A procedure outside the expose rule of the host is not the caller's to replace (R17)
332
+ if (existing && ctx?.expose && !ctx.expose(path)) {
333
+ throw new InvocationError(
334
+ "NOT_EXPOSED",
335
+ `Procedure not exposed: ${pathKey}. procedure.define can replace only a procedure that this host exposes.`,
336
+ ctx.path ?? ["procedure", "define"]
337
+ );
338
+ }
329
339
  if (existing && !isRuntimeDefined(existing)) {
330
340
  throw new Error(
331
341
  `A code procedure exists at path: ${pathKey}. procedure.define can replace only a procedure that procedure.define made.`
@@ -539,7 +549,8 @@ export const manifestProcedure: Procedure<{ namespace?: string | undefined }, Pr
539
549
  });
540
550
 
541
551
  /**
542
- * Delete a runtime-defined procedure.
552
+ * Delete a runtime-defined procedure. A procedure outside the expose rule of the host is not
553
+ * found (R17), so a caller cannot delete a hidden procedure.
543
554
  */
544
555
  export const deleteProcedureProcedure: Procedure<
545
556
  { path: ProcedurePath },
@@ -555,6 +566,10 @@ export const deleteProcedureProcedure: Procedure<
555
566
  },
556
567
  handler: async (input: { path: ProcedurePath }, ctx?: ProcedureContext): Promise<{ deleted: boolean }> => {
557
568
  const registry = ctx?.registry ?? PROCEDURE_REGISTRY;
569
+ // A procedure outside the expose rule of the host is not found (R17)
570
+ if (ctx?.expose && !ctx.expose(input.path)) {
571
+ return { deleted: false };
572
+ }
558
573
  const existing = registry.get(input.path);
559
574
  if (!existing) {
560
575
  return { deleted: false };