@warlock.js/ai-tools 4.14.0 → 4.16.0

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to `@warlock.js/ai-tools` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 4.16.0 - 2026-08-18
8
+
9
+ ### Security
10
+
11
+ - **`fetch_url` and `http_request` now deny private-network targets by default and route through `@warlock.js/ai`'s hardened `guardedFetch` instead of a local host check.** Previously `allowHosts` was the *only* SSRF guardrail and it was opt-in — a bare `ai.tools.fetchUrl()` / `ai.tools.http()` would fetch any `http(s)` URL the model supplied, including `http://169.254.169.254/latest/meta-data/...`, `localhost`, and RFC1918 addresses, and neither tool re-validated redirect targets, so even a configured allowlist could be 302'd into an internal endpoint. Both tools now issue every request through the core outbound policy, which by default refuses private / loopback / link-local / CGNAT / cloud-metadata addresses — resolving hostnames through DNS and checking every returned address, failing closed on resolution failure — and re-validates every redirect `Location` (scheme, allowlist, private-IP deny) before following it, with a hop cap and cross-origin credential-header stripping. Blocks surface as the existing typed errors (`WebToolError` `type: "denied-host"`, `HttpPolicyError` `type: "host-not-allowed"`), so agents still read them as `{ error }` data
12
+ - **New `allowPrivateNetwork` option (default `false`) on both tools** for the deliberate case of a tool that must call an internal service (e.g. a local dev server); `allowHosts` still works and now also constrains redirect targets
13
+
7
14
  ## 4.12.0
8
15
 
9
16
  ### Changed
package/cjs/index.cjs CHANGED
@@ -370,8 +370,16 @@ function isJsonContentType(contentType) {
370
370
  * - **Method allowlist** — `allowMethods` (default `["GET"]`). A method
371
371
  * outside the list is rejected with a typed
372
372
  * {@link HttpPolicyError} (`type: "method-not-allowed"`).
373
+ * - **Private-network deny (default).** The request — and every redirect
374
+ * hop — goes through the framework's `guardedFetch` outbound policy,
375
+ * which refuses private / loopback / link-local / cloud-metadata
376
+ * addresses (and hostnames resolving to them) unless
377
+ * `allowPrivateNetwork: true` is set. This applies even when
378
+ * `allowHosts` is not configured, so a bare `ai.tools.http()` is not
379
+ * an SSRF primitive (`type: "host-not-allowed"`).
373
380
  * - **Host allowlist** — when `allowHosts` is set, any other host is
374
- * rejected (`type: "host-not-allowed"`), an SSRF guardrail.
381
+ * rejected (`type: "host-not-allowed"`), an SSRF guardrail; redirect
382
+ * targets are held to the same allowlist.
375
383
  * - **`baseUrl` join** — when configured, the model passes a path that
376
384
  * is resolved against `baseUrl`; otherwise it must pass an absolute
377
385
  * `http(s)` URL. An unresolvable URL is rejected
@@ -413,6 +421,7 @@ function httpRequestTool(options = {}) {
413
421
  const allowMethods = options.allowMethods ?? ["GET"];
414
422
  const allowedMethodSet = new Set(allowMethods);
415
423
  const allowHostSet = options.allowHosts ? new Set(options.allowHosts) : void 0;
424
+ const allowPrivateNetwork = options.allowPrivateNetwork ?? false;
416
425
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
417
426
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES$1;
418
427
  const staticHeaders = options.headers;
@@ -436,22 +445,25 @@ function httpRequestTool(options = {}) {
436
445
  body = JSON.stringify(input.body);
437
446
  if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) headers["content-type"] = "application/json";
438
447
  }
439
- const controller = new AbortController();
440
- const timer = setTimeout(() => controller.abort(), timeoutMs);
441
- const onAbort = () => controller.abort();
442
- if (ctx?.signal) if (ctx.signal.aborted) controller.abort();
443
- else ctx.signal.addEventListener("abort", onAbort, { once: true });
444
448
  let response;
445
449
  try {
446
- response = await fetch(url, {
450
+ response = await (0, _warlock_js_ai.guardedFetch)(url.toString(), {
451
+ allowedSchemes: ["http", "https"],
452
+ hostAllowlist: options.allowHosts,
453
+ denyPrivateIPsAfterDNS: !allowPrivateNetwork,
454
+ timeoutMs,
455
+ signal: ctx?.signal
456
+ }, {
447
457
  method,
448
458
  headers,
449
- body,
450
- signal: controller.signal
459
+ body
460
+ });
461
+ } catch (cause) {
462
+ if (cause instanceof _warlock_js_ai.OutboundPolicyError) throw new HttpPolicyError(`http_request blocked: ${cause.message}`, {
463
+ type: "host-not-allowed",
464
+ cause
451
465
  });
452
- } finally {
453
- clearTimeout(timer);
454
- ctx?.signal?.removeEventListener("abort", onAbort);
466
+ throw cause;
455
467
  }
456
468
  const responseHeaders = {};
457
469
  response.headers.forEach((value, key) => {
@@ -2267,8 +2279,16 @@ function stripTags(html) {
2267
2279
  * `fetch` (Node 18+) and hand the model back rendered `content`.
2268
2280
  *
2269
2281
  * Guardrails, applied in order before/around the network call:
2282
+ * - **Private-network deny (default).** Every request — and every
2283
+ * redirect hop — goes through the framework's `guardedFetch` outbound
2284
+ * policy, which refuses private / loopback / link-local /
2285
+ * cloud-metadata addresses (and hostnames resolving to them) unless
2286
+ * `allowPrivateNetwork: true` is set. This applies even when
2287
+ * `allowHosts` is not configured, so a bare `ai.tools.fetchUrl()` is
2288
+ * not an SSRF primitive.
2270
2289
  * - **`allowHosts`** — when set, a request to any host not in the list is
2271
- * rejected before the fetch (an SSRF guardrail).
2290
+ * rejected before the fetch, and redirect targets are held to the same
2291
+ * allowlist.
2272
2292
  * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`
2273
2293
  * (default {@link DEFAULT_TIMEOUT_MS}).
2274
2294
  * - **`maxBytes`** — the response body is read up to this cap and the
@@ -2298,6 +2318,7 @@ function fetchUrlTool(options) {
2298
2318
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2299
2319
  const extract = options?.extract ?? DEFAULT_EXTRACT;
2300
2320
  const allowHosts = options?.allowHosts;
2321
+ const allowPrivateNetwork = options?.allowPrivateNetwork ?? false;
2301
2322
  if (extract !== "html") loadReadability();
2302
2323
  return (0, _warlock_js_ai.tool)({
2303
2324
  name: options?.name ?? DEFAULT_NAME$1,
@@ -2308,8 +2329,17 @@ function fetchUrlTool(options) {
2308
2329
  assertHostAllowed(url, allowHosts);
2309
2330
  let response;
2310
2331
  try {
2311
- response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2332
+ response = await (0, _warlock_js_ai.guardedFetch)(url.toString(), {
2333
+ allowedSchemes: ["http", "https"],
2334
+ hostAllowlist: allowHosts,
2335
+ denyPrivateIPsAfterDNS: !allowPrivateNetwork,
2336
+ timeoutMs
2337
+ });
2312
2338
  } catch (cause) {
2339
+ if (cause instanceof _warlock_js_ai.OutboundPolicyError) throw new WebToolError(`fetch_url blocked: ${cause.message}`, {
2340
+ type: "denied-host",
2341
+ cause
2342
+ });
2313
2343
  throw new WebToolError(`fetch_url request failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
2314
2344
  type: "request-failed",
2315
2345
  cause