@dexto/tools-builtins 1.16.6 → 1.17.0-beta.5

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.
@@ -51,7 +51,12 @@ describe("http_request tool", () => {
51
51
  "[0:0:0:0:0:0:0:1]",
52
52
  "[::ffff:127.0.0.1]",
53
53
  "[::ffff:10.0.0.1]",
54
- "[64:ff9b:1::a00:1]"
54
+ "[64:ff9b:1::a00:1]",
55
+ "[64:ff9b::7f00:1]",
56
+ "[fec0::1]",
57
+ "[feff::1]",
58
+ "[ff02::1]",
59
+ "[ff00::1]"
55
60
  ])("blocks private IPv6 literal %s before fetching", async (host) => {
56
61
  const fetchSpy = vi.spyOn(globalThis, "fetch");
57
62
  try {
@@ -66,6 +71,32 @@ describe("http_request tool", () => {
66
71
  fetchSpy.mockRestore();
67
72
  }
68
73
  });
74
+ it("refuses a name that answers a private address without echoing the answer", async () => {
75
+ const dnsLookup = vi.fn().mockResolvedValue([
76
+ { address: "93.184.216.34", family: 4 },
77
+ { address: "10.20.30.40", family: 4 }
78
+ ]);
79
+ const error = await new Promise((resolve) => {
80
+ createSafeLookup({ dnsLookup })(
81
+ "files.example.com",
82
+ { all: true },
83
+ ((err) => resolve(err))
84
+ );
85
+ });
86
+ expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
87
+ expect(error?.message).toBe("Request to files.example.com is blocked");
88
+ });
89
+ it.each(["localhost", "10.0.0.1", "[::1]"])(
90
+ "refuses %s with a message that only names the host",
91
+ async (host) => {
92
+ await expect(
93
+ createHttpRequestTool().execute(
94
+ { url: `http://${host}/`, method: "GET", timeoutMs: 1e3 },
95
+ {}
96
+ )
97
+ ).rejects.toMatchObject({ message: `Request to ${host} is blocked` });
98
+ }
99
+ );
69
100
  it("blocks a redirect to a private literal before the next request", async () => {
70
101
  const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
71
102
  new Response(null, { status: 302, headers: { location: "http://[::1]/secret" } })
@@ -123,4 +154,220 @@ describe("http_request tool", () => {
123
154
  fetchSpy.mockRestore();
124
155
  }
125
156
  });
157
+ it.each([307, 308])(
158
+ "refuses a %i redirect that would send the body to another origin",
159
+ async (status) => {
160
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
161
+ new Response(null, {
162
+ status,
163
+ headers: { location: "https://other.example/collect" }
164
+ })
165
+ ).mockResolvedValueOnce(new Response("ok"));
166
+ try {
167
+ await expect(
168
+ createHttpRequestTool().execute(
169
+ {
170
+ url: "https://example.com/submit",
171
+ method: "POST",
172
+ body: { note: "private text" },
173
+ timeoutMs: 1e3
174
+ },
175
+ {}
176
+ )
177
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
178
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
179
+ } finally {
180
+ fetchSpy.mockRestore();
181
+ }
182
+ }
183
+ );
184
+ it.each([
185
+ ["PUT", 301],
186
+ ["PATCH", 301],
187
+ ["PUT", 302],
188
+ ["DELETE", 302],
189
+ ["PATCH", 302]
190
+ ])(
191
+ "refuses a %s %i redirect that would send the body to another origin",
192
+ async (method, status) => {
193
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
194
+ new Response(null, {
195
+ status,
196
+ headers: { location: "https://other.example/collect" }
197
+ })
198
+ ).mockResolvedValueOnce(new Response("ok"));
199
+ try {
200
+ await expect(
201
+ createHttpRequestTool().execute(
202
+ {
203
+ url: "https://example.com/item",
204
+ method,
205
+ body: "private text",
206
+ timeoutMs: 1e3
207
+ },
208
+ {}
209
+ )
210
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_REDIRECT_BLOCKED" });
211
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
212
+ } finally {
213
+ fetchSpy.mockRestore();
214
+ }
215
+ }
216
+ );
217
+ it.each([301, 302])(
218
+ "still turns a cross-origin %i redirect of a POST into a body-less GET",
219
+ async (status) => {
220
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
221
+ new Response(null, { status, headers: { location: "https://other.example/" } })
222
+ ).mockResolvedValueOnce(new Response("ok"));
223
+ try {
224
+ await createHttpRequestTool().execute(
225
+ {
226
+ url: "https://example.com/",
227
+ method: "POST",
228
+ body: "payload",
229
+ timeoutMs: 1e3
230
+ },
231
+ {}
232
+ );
233
+ expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "GET" });
234
+ expect(fetchSpy.mock.calls[1]?.[1]).not.toHaveProperty("body");
235
+ } finally {
236
+ fetchSpy.mockRestore();
237
+ }
238
+ }
239
+ );
240
+ it.each([307, 308])("keeps the body on a same-origin %i redirect", async (status) => {
241
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status, headers: { location: "/moved" } })).mockResolvedValueOnce(new Response("ok"));
242
+ try {
243
+ await createHttpRequestTool().execute(
244
+ {
245
+ url: "https://example.com/submit",
246
+ method: "POST",
247
+ body: "payload",
248
+ timeoutMs: 1e3
249
+ },
250
+ {}
251
+ );
252
+ expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://example.com/moved");
253
+ expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ method: "POST", body: "payload" });
254
+ } finally {
255
+ fetchSpy.mockRestore();
256
+ }
257
+ });
258
+ it("still follows a cross-origin 307 when there is no body", async () => {
259
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
260
+ new Response(null, {
261
+ status: 307,
262
+ headers: { location: "https://other.example/" }
263
+ })
264
+ ).mockResolvedValueOnce(new Response("ok"));
265
+ try {
266
+ const result = await createHttpRequestTool().execute(
267
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
268
+ {}
269
+ );
270
+ expect(result).toMatchObject({ ok: true, body: "ok" });
271
+ } finally {
272
+ fetchSpy.mockRestore();
273
+ }
274
+ });
275
+ });
276
+ describe("http_request cloud metadata hosts", () => {
277
+ const METADATA_HOSTS = [
278
+ "metadata.google.internal",
279
+ "METADATA.GOOG",
280
+ "instance-data.",
281
+ "instance-data.ec2.internal",
282
+ "169.254.169.254",
283
+ "[fd00:ec2::254]",
284
+ "100.100.100.200",
285
+ // Not private ranges, so only the metadata check refuses them.
286
+ "168.63.129.16",
287
+ "[::ffff:a83f:8110]"
288
+ ];
289
+ it.each(METADATA_HOSTS)("refuses %s before fetching", async (host) => {
290
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
291
+ try {
292
+ await expect(
293
+ createHttpRequestTool().execute(
294
+ { url: `http://${host}/latest/meta-data/`, method: "GET", timeoutMs: 1e3 },
295
+ {}
296
+ )
297
+ ).rejects.toMatchObject({
298
+ code: "HTTP_REQUEST_UNSAFE_TARGET",
299
+ message: `Request to ${new URL(`http://${host}`).hostname} is blocked`
300
+ });
301
+ expect(fetchSpy).not.toHaveBeenCalled();
302
+ } finally {
303
+ fetchSpy.mockRestore();
304
+ }
305
+ });
306
+ it("refuses a redirect to a metadata host name", async () => {
307
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
308
+ new Response(null, {
309
+ status: 302,
310
+ headers: { location: "http://metadata.google.internal/computeMetadata/v1/" }
311
+ })
312
+ );
313
+ try {
314
+ await expect(
315
+ createHttpRequestTool().execute(
316
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
317
+ {}
318
+ )
319
+ ).rejects.toMatchObject({
320
+ code: "HTTP_REQUEST_UNSAFE_TARGET",
321
+ message: "Request to metadata.google.internal is blocked"
322
+ });
323
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
324
+ } finally {
325
+ fetchSpy.mockRestore();
326
+ }
327
+ });
328
+ it.each([
329
+ // Not private ranges, so only the metadata check refuses them.
330
+ "168.63.129.16",
331
+ "::ffff:168.63.129.16",
332
+ // Also private ranges.
333
+ "169.254.169.254",
334
+ "fd00:ec2::254",
335
+ "192.0.0.192",
336
+ "100.100.100.200"
337
+ ])(
338
+ "the connection lookup refuses a public name that answers the metadata address %s",
339
+ async (address) => {
340
+ const dnsLookup = vi.fn().mockResolvedValue([
341
+ { address: "93.184.216.34", family: 4 },
342
+ { address, family: 4 }
343
+ ]);
344
+ const safeLookup = createSafeLookup({ dnsLookup });
345
+ const error = await new Promise((resolve) => {
346
+ safeLookup(
347
+ "files.example.com",
348
+ { all: true },
349
+ ((err) => resolve(err))
350
+ );
351
+ });
352
+ expect(dnsLookup).toHaveBeenCalledOnce();
353
+ expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
354
+ expect(error?.message).toBe("Request to files.example.com is blocked");
355
+ }
356
+ );
357
+ it.each(["metadata.google.internal", "metadata.goog", "instance-data"])(
358
+ "the connection lookup refuses %s without resolving it",
359
+ async (host) => {
360
+ const dnsLookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
361
+ const safeLookup = createSafeLookup({ dnsLookup });
362
+ const error = await new Promise((resolve) => {
363
+ safeLookup(
364
+ host,
365
+ { all: true },
366
+ ((err) => resolve(err))
367
+ );
368
+ });
369
+ expect(error).toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
370
+ expect(dnsLookup).not.toHaveBeenCalled();
371
+ }
372
+ );
126
373
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexto/tools-builtins",
3
- "version": "1.16.6",
3
+ "version": "1.17.0-beta.5",
4
4
  "description": "Built-in tools factory for Dexto agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "dependencies": {
20
20
  "undici": "^7.30.1",
21
21
  "zod": "^4.3.6",
22
- "@dexto/agent-config": "1.16.6",
23
- "@dexto/core": "1.16.6"
22
+ "@dexto/agent-config": "1.17.0-beta.5",
23
+ "@dexto/core": "1.17.0-beta.5"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.0.0",