@dexto/tools-builtins 1.15.0 → 1.16.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.
@@ -52,6 +52,7 @@ function safeJsonParse(text) {
52
52
  }
53
53
  const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost"]);
54
54
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
55
+ const MAX_REDIRECTS = 20;
55
56
  const SAFE_DISPATCHER = createSafeDispatcher();
56
57
  function isPrivateIpv4(ip) {
57
58
  const parts = ip.split(".").map((part) => Number(part));
@@ -82,20 +83,26 @@ function isPrivateIpv4(ip) {
82
83
  return false;
83
84
  }
84
85
  function isPrivateIpv6(ip) {
85
- const normalized = ip.toLowerCase();
86
+ const normalized = new URL(`http://[${ip.split("%")[0]}]/`).hostname.slice(1, -1);
86
87
  if (normalized === "::" || normalized === "::1") return true;
87
- if (normalized.startsWith("fe80:")) return true;
88
+ if (normalized.startsWith("64:ff9b:1:")) return true;
89
+ if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true;
88
90
  if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
89
91
  if (normalized.startsWith("::ffff:")) {
90
- const ipv4Part = normalized.slice("::ffff:".length);
91
- return isPrivateIpv4(ipv4Part);
92
+ const parts = normalized.slice("::ffff:".length).split(":");
93
+ if (parts.length === 2) {
94
+ const high = Number.parseInt(parts[0] ?? "", 16);
95
+ const low = Number.parseInt(parts[1] ?? "", 16);
96
+ return isPrivateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
97
+ }
92
98
  }
93
99
  return false;
94
100
  }
95
101
  function isPrivateAddress(ip) {
96
- const version = (0, import_node_net.isIP)(ip);
97
- if (version === 4) return isPrivateIpv4(ip);
98
- if (version === 6) return isPrivateIpv6(ip);
102
+ const address = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
103
+ const version = (0, import_node_net.isIP)(address);
104
+ if (version === 4) return isPrivateIpv4(address);
105
+ if (version === 6) return isPrivateIpv6(address);
99
106
  return false;
100
107
  }
101
108
  function createSafeLookup(config) {
@@ -331,7 +338,39 @@ function createHttpRequestTool() {
331
338
  if (requestBody !== void 0) {
332
339
  requestInit.body = requestBody;
333
340
  }
334
- const response = await fetch(requestUrl.toString(), requestInit);
341
+ requestInit.redirect = "manual";
342
+ let currentUrl = requestUrl;
343
+ let response;
344
+ for (let redirects = 0; ; redirects++) {
345
+ await assertSafeUrl(currentUrl);
346
+ response = await fetch(currentUrl.toString(), requestInit);
347
+ const location = response.headers.get("location");
348
+ if (![301, 302, 303, 307, 308].includes(response.status) || !location) break;
349
+ await response.body?.cancel();
350
+ if (redirects >= MAX_REDIRECTS) {
351
+ throw new import_errors.DextoRuntimeError(
352
+ "HTTP_REQUEST_TOO_MANY_REDIRECTS",
353
+ import_errors.ErrorScope.TOOLS,
354
+ import_errors.ErrorType.THIRD_PARTY,
355
+ `HTTP request exceeded ${MAX_REDIRECTS} redirects`
356
+ );
357
+ }
358
+ const nextUrl = new URL(location, currentUrl);
359
+ if (nextUrl.origin !== currentUrl.origin) {
360
+ for (const key of Object.keys(requestHeaders)) {
361
+ delete requestHeaders[key];
362
+ }
363
+ }
364
+ if (response.status === 303 || (response.status === 301 || response.status === 302) && requestInit.method === "POST") {
365
+ requestInit.method = "GET";
366
+ delete requestInit.body;
367
+ for (const key of Object.keys(requestHeaders)) {
368
+ if (["content-type", "content-length"].includes(key.toLowerCase()))
369
+ delete requestHeaders[key];
370
+ }
371
+ }
372
+ currentUrl = nextUrl;
373
+ }
335
374
  const responseText = await readResponseTextWithLimit(response);
336
375
  const contentType = response.headers.get("content-type");
337
376
  const json = isJsonContentType(contentType) ? safeJsonParse(responseText) : void 0;
@@ -1 +1 @@
1
- {"version":3,"file":"http-request-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/http-request-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AAQpE,OAAO,EAAE,QAAQ,IAAI,GAAG,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAKnF,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;kBA2Bf,CAAC;AA8Bd,KAAK,cAAc,GACb,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,GAC9E,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC,CAAC;AAoDhF,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE;IACtC,SAAS,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,CAAC;CACjC,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,CAyI/E;AA2FD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAAC,OAAO,sBAAsB,CAAC,CAwG3E"}
1
+ {"version":3,"file":"http-request-tool.d.ts","sourceRoot":"","sources":["../../src/implementations/http-request-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,IAAI,EAAwB,MAAM,mBAAmB,CAAC;AAQpE,OAAO,EAAE,QAAQ,IAAI,GAAG,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAKnF,QAAA,MAAM,sBAAsB;;;;;;;;;;;;;kBA2Bf,CAAC;AA+Bd,KAAK,cAAc,GACb,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,GAC9E,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC,CAAC;AA4DhF,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE;IACtC,SAAS,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,CAAC;CACjC,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,CAyI/E;AA2FD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAAC,OAAO,sBAAsB,CAAC,CA4I3E"}
@@ -33,6 +33,7 @@ function safeJsonParse(text) {
33
33
  }
34
34
  const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost"]);
35
35
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
36
+ const MAX_REDIRECTS = 20;
36
37
  const SAFE_DISPATCHER = createSafeDispatcher();
37
38
  function isPrivateIpv4(ip) {
38
39
  const parts = ip.split(".").map((part) => Number(part));
@@ -63,20 +64,26 @@ function isPrivateIpv4(ip) {
63
64
  return false;
64
65
  }
65
66
  function isPrivateIpv6(ip) {
66
- const normalized = ip.toLowerCase();
67
+ const normalized = new URL(`http://[${ip.split("%")[0]}]/`).hostname.slice(1, -1);
67
68
  if (normalized === "::" || normalized === "::1") return true;
68
- if (normalized.startsWith("fe80:")) return true;
69
+ if (normalized.startsWith("64:ff9b:1:")) return true;
70
+ if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true;
69
71
  if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
70
72
  if (normalized.startsWith("::ffff:")) {
71
- const ipv4Part = normalized.slice("::ffff:".length);
72
- return isPrivateIpv4(ipv4Part);
73
+ const parts = normalized.slice("::ffff:".length).split(":");
74
+ if (parts.length === 2) {
75
+ const high = Number.parseInt(parts[0] ?? "", 16);
76
+ const low = Number.parseInt(parts[1] ?? "", 16);
77
+ return isPrivateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
78
+ }
73
79
  }
74
80
  return false;
75
81
  }
76
82
  function isPrivateAddress(ip) {
77
- const version = isIP(ip);
78
- if (version === 4) return isPrivateIpv4(ip);
79
- if (version === 6) return isPrivateIpv6(ip);
83
+ const address = ip.startsWith("[") && ip.endsWith("]") ? ip.slice(1, -1) : ip;
84
+ const version = isIP(address);
85
+ if (version === 4) return isPrivateIpv4(address);
86
+ if (version === 6) return isPrivateIpv6(address);
80
87
  return false;
81
88
  }
82
89
  function createSafeLookup(config) {
@@ -312,7 +319,39 @@ function createHttpRequestTool() {
312
319
  if (requestBody !== void 0) {
313
320
  requestInit.body = requestBody;
314
321
  }
315
- const response = await fetch(requestUrl.toString(), requestInit);
322
+ requestInit.redirect = "manual";
323
+ let currentUrl = requestUrl;
324
+ let response;
325
+ for (let redirects = 0; ; redirects++) {
326
+ await assertSafeUrl(currentUrl);
327
+ response = await fetch(currentUrl.toString(), requestInit);
328
+ const location = response.headers.get("location");
329
+ if (![301, 302, 303, 307, 308].includes(response.status) || !location) break;
330
+ await response.body?.cancel();
331
+ if (redirects >= MAX_REDIRECTS) {
332
+ throw new DextoRuntimeError(
333
+ "HTTP_REQUEST_TOO_MANY_REDIRECTS",
334
+ ErrorScope.TOOLS,
335
+ ErrorType.THIRD_PARTY,
336
+ `HTTP request exceeded ${MAX_REDIRECTS} redirects`
337
+ );
338
+ }
339
+ const nextUrl = new URL(location, currentUrl);
340
+ if (nextUrl.origin !== currentUrl.origin) {
341
+ for (const key of Object.keys(requestHeaders)) {
342
+ delete requestHeaders[key];
343
+ }
344
+ }
345
+ if (response.status === 303 || (response.status === 301 || response.status === 302) && requestInit.method === "POST") {
346
+ requestInit.method = "GET";
347
+ delete requestInit.body;
348
+ for (const key of Object.keys(requestHeaders)) {
349
+ if (["content-type", "content-length"].includes(key.toLowerCase()))
350
+ delete requestHeaders[key];
351
+ }
352
+ }
353
+ currentUrl = nextUrl;
354
+ }
316
355
  const responseText = await readResponseTextWithLimit(response);
317
356
  const contentType = response.headers.get("content-type");
318
357
  const json = isJsonContentType(contentType) ? safeJsonParse(responseText) : void 0;
@@ -47,4 +47,81 @@ var import_http_request_tool = require("./http-request-tool.js");
47
47
  code: "HTTP_REQUEST_UNSAFE_TARGET"
48
48
  });
49
49
  });
50
+ import_vitest.it.each([
51
+ "[::1]",
52
+ "[0:0:0:0:0:0:0:1]",
53
+ "[::ffff:127.0.0.1]",
54
+ "[::ffff:10.0.0.1]",
55
+ "[64:ff9b:1::a00:1]"
56
+ ])("blocks private IPv6 literal %s before fetching", async (host) => {
57
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch");
58
+ try {
59
+ await (0, import_vitest.expect)(
60
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
61
+ { url: `http://${host}/`, method: "GET", timeoutMs: 1e3 },
62
+ {}
63
+ )
64
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
65
+ (0, import_vitest.expect)(fetchSpy).not.toHaveBeenCalled();
66
+ } finally {
67
+ fetchSpy.mockRestore();
68
+ }
69
+ });
70
+ (0, import_vitest.it)("blocks a redirect to a private literal before the next request", async () => {
71
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValue(
72
+ new Response(null, { status: 302, headers: { location: "http://[::1]/secret" } })
73
+ );
74
+ try {
75
+ await (0, import_vitest.expect)(
76
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
77
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
78
+ {}
79
+ )
80
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
81
+ (0, import_vitest.expect)(fetchSpy).toHaveBeenCalledTimes(1);
82
+ (0, import_vitest.expect)(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
83
+ } finally {
84
+ fetchSpy.mockRestore();
85
+ }
86
+ });
87
+ (0, import_vitest.it)("follows relative redirects and limits redirect loops", async () => {
88
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockImplementation(
89
+ async () => new Response(null, { status: 302, headers: { location: "/next" } })
90
+ );
91
+ try {
92
+ await (0, import_vitest.expect)(
93
+ (0, import_http_request_tool.createHttpRequestTool)().execute(
94
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
95
+ {}
96
+ )
97
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_TOO_MANY_REDIRECTS" });
98
+ (0, import_vitest.expect)(fetchSpy).toHaveBeenCalledTimes(21);
99
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[0]).toBe("https://example.com/next");
100
+ } finally {
101
+ fetchSpy.mockRestore();
102
+ }
103
+ });
104
+ (0, import_vitest.it)("drops credentials when following a cross-origin redirect", async () => {
105
+ const fetchSpy = import_vitest.vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
106
+ new Response(null, { status: 302, headers: { location: "https://other.example/" } })
107
+ ).mockResolvedValueOnce(new Response("ok"));
108
+ try {
109
+ await (0, import_http_request_tool.createHttpRequestTool)().execute(
110
+ {
111
+ url: "https://example.com/",
112
+ method: "GET",
113
+ headers: {
114
+ Authorization: "secret",
115
+ Cookie: "session=value",
116
+ "X-API-Key": "secret"
117
+ },
118
+ timeoutMs: 1e3
119
+ },
120
+ {}
121
+ );
122
+ (0, import_vitest.expect)(fetchSpy.mock.calls[1]?.[1]?.headers).toEqual({});
123
+ } finally {
124
+ fetchSpy.mockRestore();
125
+ }
126
+ });
50
127
  });
@@ -46,4 +46,81 @@ describe("http_request tool", () => {
46
46
  code: "HTTP_REQUEST_UNSAFE_TARGET"
47
47
  });
48
48
  });
49
+ it.each([
50
+ "[::1]",
51
+ "[0:0:0:0:0:0:0:1]",
52
+ "[::ffff:127.0.0.1]",
53
+ "[::ffff:10.0.0.1]",
54
+ "[64:ff9b:1::a00:1]"
55
+ ])("blocks private IPv6 literal %s before fetching", async (host) => {
56
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
57
+ try {
58
+ await expect(
59
+ createHttpRequestTool().execute(
60
+ { url: `http://${host}/`, method: "GET", timeoutMs: 1e3 },
61
+ {}
62
+ )
63
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
64
+ expect(fetchSpy).not.toHaveBeenCalled();
65
+ } finally {
66
+ fetchSpy.mockRestore();
67
+ }
68
+ });
69
+ it("blocks a redirect to a private literal before the next request", async () => {
70
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
71
+ new Response(null, { status: 302, headers: { location: "http://[::1]/secret" } })
72
+ );
73
+ try {
74
+ await expect(
75
+ createHttpRequestTool().execute(
76
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
77
+ {}
78
+ )
79
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_UNSAFE_TARGET" });
80
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
81
+ expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
82
+ } finally {
83
+ fetchSpy.mockRestore();
84
+ }
85
+ });
86
+ it("follows relative redirects and limits redirect loops", async () => {
87
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
88
+ async () => new Response(null, { status: 302, headers: { location: "/next" } })
89
+ );
90
+ try {
91
+ await expect(
92
+ createHttpRequestTool().execute(
93
+ { url: "https://example.com/", method: "GET", timeoutMs: 1e3 },
94
+ {}
95
+ )
96
+ ).rejects.toMatchObject({ code: "HTTP_REQUEST_TOO_MANY_REDIRECTS" });
97
+ expect(fetchSpy).toHaveBeenCalledTimes(21);
98
+ expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://example.com/next");
99
+ } finally {
100
+ fetchSpy.mockRestore();
101
+ }
102
+ });
103
+ it("drops credentials when following a cross-origin redirect", async () => {
104
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
105
+ new Response(null, { status: 302, headers: { location: "https://other.example/" } })
106
+ ).mockResolvedValueOnce(new Response("ok"));
107
+ try {
108
+ await createHttpRequestTool().execute(
109
+ {
110
+ url: "https://example.com/",
111
+ method: "GET",
112
+ headers: {
113
+ Authorization: "secret",
114
+ Cookie: "session=value",
115
+ "X-API-Key": "secret"
116
+ },
117
+ timeoutMs: 1e3
118
+ },
119
+ {}
120
+ );
121
+ expect(fetchSpy.mock.calls[1]?.[1]?.headers).toEqual({});
122
+ } finally {
123
+ fetchSpy.mockRestore();
124
+ }
125
+ });
49
126
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dexto/tools-builtins",
3
- "version": "1.15.0",
3
+ "version": "1.16.1",
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.15.0",
23
- "@dexto/core": "1.15.0"
22
+ "@dexto/agent-config": "1.16.1",
23
+ "@dexto/core": "1.16.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.0.0",