pi-unsloth-webtools 0.5.1 → 0.6.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/README.md CHANGED
@@ -50,11 +50,19 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
50
50
  - URL validation: http/https only, no credentials or encoded hostnames, hostname/port checks (any
51
51
  port 1–65535 is permitted; SSRF protection is enforced at the resolved-IP layer, not by port
52
52
  allowlists).
53
- Canonical public IPv4 literals are accepted like IPv6 literals; private literals are still
54
- blocked at the resolved-IP layer (non-canonical numeric encodings like `0x7f.0.0.1` /
55
- `013.0.0.1` / `2130706433` are rejected by URL validation before DNS).
53
+ Canonical public IPv4 literals are accepted like IPv6 literals; private literals are blocked
54
+ at the resolved-IP layer unless `webFetch.allowPrivateAddresses` is enabled (the default;
55
+ non-canonical numeric encodings like `0x7f.0.0.1` / `013.0.0.1` / `2130706433` are rejected
56
+ by URL validation before DNS either way).
57
+ - Local access by default: private/loopback/link-local targets (localhost dev servers, LAN hosts)
58
+ and local files (`file://` URLs, absolute, `~/`, and `./` paths) are fetched directly — local
59
+ PDFs run the same MuPDF extraction, local HTML gets the same Markdown conversion and metadata
60
+ prefix, and the same byte caps and truncation notices apply. Opt out with
61
+ `webFetch.allowPrivateAddresses: false` to restore the resolved-IP SSRF guard, or
62
+ `webFetch.allowLocalFiles: false` to refuse local paths; both are settings, not tool parameters.
56
63
  - DNS resolution with SSRF protection: every resolved address is validated against
57
- private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges, then the validated IP
64
+ private/loopback/link-local/CGNAT/documentation/multicast/reserved ranges (unless
65
+ `webFetch.allowPrivateAddresses` is enabled, the default), then the validated IP
58
66
  is pinned for the connection (custom `lookup` + SNI `servername`), so DNS cannot rebind between
59
67
  validation and fetch; resolution shares the caller's abort signal and the overall deadline,
60
68
  so a stuck resolver cannot outlive the fetch.
@@ -177,6 +185,8 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
177
185
  | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch and `web_search` url mode fall back to 60000; `web_search` query mode falls back to 300000 |
178
186
  | `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
179
187
  | `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
188
+ | `unslothWebTools.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Opt out to restore the resolved-IP SSRF guard: private/loopback/link-local hosts (localhost, LAN IPs) are refused again. Non-canonical numeric IP encodings stay blocked either way |
189
+ | `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` and `web_search` url mode (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
180
190
 
181
191
  Tool params always win over file defaults. Search dedup also strips default ports, so `https://example.com:443/a` and `https://example.com/a` collapse.
182
192
 
@@ -193,7 +203,10 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
193
203
  | Search timeout | `Search failed: the search engines (...) did not respond within N seconds.` | Retry, narrow the query, or raise `timeoutMs`. |
194
204
  | No results | `No results found.` | Rephrase the query. |
195
205
  | Policy filtered everything | `No results found within the website access limits.` | Widen `websitePolicy`, do not work around it. |
196
- | Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-public IPs, and policy denials. |
206
+ | Blocked URL | `Blocked: ...` | Respect it. Includes non-http schemes, credentials, invalid hosts, non-canonical numeric IP encodings, and policy denials. |
207
+ | Private address blocked | `Blocked: refusing to fetch the non-public address ...` | The SSRF guard is active (`allowPrivateAddresses: false`); remove it or set `true` to reach localhost/LAN, and write the scheme explicitly (`http://localhost:3000`). |
208
+ | Local file blocked | `Blocked: the URL has an invalid hostname or port.` for paths | Local files are disabled: remove `allowLocalFiles: false` to read `file://`, absolute, `~/`, or `./` paths. |
209
+ | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
197
210
  | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot. |
198
211
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
199
212
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
package/index.ts CHANGED
@@ -12,11 +12,12 @@ function positiveNumber(value: unknown): number | undefined {
12
12
  async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
13
13
  const timeoutParam = positiveNumber(params.timeoutMs);
14
14
  const maxCharsParam = positiveNumber(params.maxChars);
15
- if (timeoutParam !== undefined && maxCharsParam !== undefined) return { timeoutMs: timeoutParam, maxChars: maxCharsParam };
16
15
  const defaults = await loadDefaultFetchSettings(cwd);
17
16
  return {
18
17
  timeoutMs: timeoutParam ?? defaults.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
19
18
  maxChars: maxCharsParam ?? defaults.maxChars,
19
+ allowPrivateAddresses: defaults.allowPrivateAddresses,
20
+ allowLocalFiles: defaults.allowLocalFiles,
20
21
  };
21
22
  }
22
23
 
@@ -91,7 +92,7 @@ export function createWebTools(deps: WebToolsDeps = {}) {
91
92
  const url = params.url.trim();
92
93
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
93
94
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
94
- const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
95
+ const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
95
96
  return {
96
97
  content: [
97
98
  {
@@ -100,6 +101,8 @@ export function createWebTools(deps: WebToolsDeps = {}) {
100
101
  timeoutMs,
101
102
  signal: signal ?? undefined,
102
103
  maxChars,
104
+ allowPrivateAddresses,
105
+ allowLocalFiles,
103
106
  }),
104
107
  },
105
108
  ],
@@ -127,18 +130,21 @@ export function createWebTools(deps: WebToolsDeps = {}) {
127
130
  "main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
128
131
  "stripping. Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
129
132
  "README API, so the README is returned instead of the repo page's UI chrome. " +
130
- "Private/loopback/link-local targets are blocked (SSRF protection), and the download size " +
131
- "is capped.",
133
+ "Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
134
+ "PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
135
+ "webFetch.allowLocalFiles: false in settings. The download size is capped.",
132
136
  promptSnippet: "Fetch a web page and return readable text content",
133
137
  parameters: WebFetchParams,
134
138
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
135
139
  onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
136
140
  const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
137
- const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
141
+ const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
138
142
  const text = await fetchPageText(params.url, {
139
143
  timeoutMs,
140
144
  signal: signal ?? undefined,
141
145
  maxChars,
146
+ allowPrivateAddresses,
147
+ allowLocalFiles,
142
148
  });
143
149
  return { content: [{ type: "text", text }], details: {} };
144
150
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
package/settings.ts CHANGED
@@ -34,6 +34,21 @@ function pickNumber(data: Record<string, unknown>, paths: string[][]): number |
34
34
  return undefined;
35
35
  }
36
36
 
37
+ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean | undefined {
38
+ for (const path of paths) {
39
+ let cur: unknown = data;
40
+ for (const key of path) {
41
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
42
+ else {
43
+ cur = undefined;
44
+ break;
45
+ }
46
+ }
47
+ if (typeof cur === "boolean") return cur;
48
+ }
49
+ return undefined;
50
+ }
51
+
37
52
  const MAX_RESULTS = 5;
38
53
 
39
54
  const MAX_RESULTS_PATHS: string[][] = [
@@ -54,6 +69,16 @@ const FETCH_TIMEOUT_PATHS: string[][] = [
54
69
  ["smartFetchDefaultTimeoutMs"],
55
70
  ];
56
71
 
72
+ const ALLOW_PRIVATE_ADDRESSES_PATHS: string[][] = [
73
+ ["unslothWebTools", "allowPrivateAddresses"],
74
+ ["webFetch", "allowPrivateAddresses"],
75
+ ];
76
+
77
+ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
78
+ ["unslothWebTools", "allowLocalFiles"],
79
+ ["webFetch", "allowLocalFiles"],
80
+ ];
81
+
57
82
  function clampMaxResults(value: number): number {
58
83
  return Math.min(20, Math.max(1, value));
59
84
  }
@@ -101,14 +126,25 @@ export async function loadDefaultFetchTimeoutMs(cwd?: string): Promise<number |
101
126
  return loadFetchSetting(cwd, FETCH_TIMEOUT_PATHS, (n) => n >= 1000);
102
127
  }
103
128
 
104
- export async function loadDefaultFetchSettings(cwd?: string): Promise<{ maxChars?: number; timeoutMs?: number }> {
129
+ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
130
+ maxChars?: number;
131
+ timeoutMs?: number;
132
+ allowPrivateAddresses: boolean;
133
+ allowLocalFiles: boolean;
134
+ }> {
105
135
  let maxChars: number | undefined;
106
136
  let timeoutMs: number | undefined;
137
+ let allowPrivateAddresses = true;
138
+ let allowLocalFiles = true;
107
139
  for (const data of await settingsEntries(cwd)) {
108
140
  const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
109
141
  if (c !== undefined && c > 0) maxChars = c;
110
142
  const t = pickNumber(data, FETCH_TIMEOUT_PATHS);
111
143
  if (t !== undefined && t >= 1000) timeoutMs = t;
144
+ const p = pickBoolean(data, ALLOW_PRIVATE_ADDRESSES_PATHS);
145
+ if (p !== undefined) allowPrivateAddresses = p;
146
+ const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
147
+ if (l !== undefined) allowLocalFiles = l;
112
148
  }
113
- return { maxChars, timeoutMs };
149
+ return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
114
150
  }
package/web-fetch.ts CHANGED
@@ -2,6 +2,11 @@ import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import type { LookupAllOptions } from "node:dns";
3
3
  import http from "node:http";
4
4
  import https from "node:https";
5
+ import { open } from "node:fs/promises";
6
+ import type { FileHandle } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
5
10
 
6
11
  import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
7
12
  import type { IncomingMessage } from "node:http";
@@ -171,6 +176,8 @@ export interface FetchPageOptions {
171
176
  maxChars?: number;
172
177
  maxBytes?: number;
173
178
  maxPdfBytes?: number;
179
+ allowPrivateAddresses?: boolean;
180
+ allowLocalFiles?: boolean;
174
181
  seams?: FetchSeams;
175
182
  rawFetch?: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>;
176
183
  }
@@ -191,7 +198,7 @@ export interface ResolvedHost {
191
198
  }
192
199
 
193
200
  export interface FetchSeams {
194
- resolve?: (hostname: string, signal?: AbortSignal) => Promise<ResolvedHost>;
201
+ resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
195
202
  request?: (opts: HopOptions) => Promise<HopResponse>;
196
203
  }
197
204
 
@@ -217,6 +224,7 @@ export interface RawFetchOptions {
217
224
  websitePolicy?: WebsitePolicy | null;
218
225
  maxBytes?: number;
219
226
  maxPdfBytes?: number;
227
+ allowPrivateAddresses?: boolean;
220
228
  seams?: FetchSeams;
221
229
  }
222
230
 
@@ -549,7 +557,11 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
549
557
  });
550
558
  }
551
559
 
552
- async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
560
+ async function resolveAndValidate(
561
+ hostname: string,
562
+ signal?: AbortSignal,
563
+ allowPrivateAddresses = false,
564
+ ): Promise<ResolvedHost> {
553
565
  let lastError: unknown = null;
554
566
  for (let attempt = 0; attempt < MAX_DNS_ATTEMPTS; attempt++) {
555
567
  if (signal?.aborted) break;
@@ -568,7 +580,7 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
568
580
  return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
569
581
  }
570
582
  for (const entry of addresses) {
571
- if (!isPublicIp(entry.address)) {
583
+ if (!allowPrivateAddresses && !isPublicIp(entry.address)) {
572
584
  return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
573
585
  }
574
586
  }
@@ -813,6 +825,7 @@ export async function fetchUrlRaw(
813
825
  const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
814
826
  const seams = options.seams ?? {};
815
827
  const resolveHost = seams.resolve ?? resolveAndValidate;
828
+ const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
816
829
  const performRequest = seams.request ?? requestHop;
817
830
  const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
818
831
  const abortController = new AbortController();
@@ -824,7 +837,7 @@ export async function fetchUrlRaw(
824
837
  deadlineId = setTimeout(() => resolve({ ok: false, reason: FETCH_TIMEOUT_MESSAGE, ip: "", family: 0 }), waitMs);
825
838
  });
826
839
  try {
827
- const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
840
+ const resolved = await Promise.race([resolveHost(hostname, resolveSignal, allowPrivateAddresses), deadlinePromise]);
828
841
  if (resolved.ok) return resolved;
829
842
  if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
830
843
  if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
@@ -853,7 +866,9 @@ export async function fetchUrlRaw(
853
866
  const budgetResult = checkBudget();
854
867
  if (budgetResult !== null) return budgetResult;
855
868
  const parsed = new URL(currentUrl);
856
- const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
869
+ const hostHeader = parsed.hostname.includes(":")
870
+ ? `[${parsed.hostname}]${parsed.port ? `:${parsed.port}` : ""}`
871
+ : parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
857
872
  const headers: Record<string, string> = {
858
873
  "User-Agent": userAgent,
859
874
  Host: hostHeader,
@@ -1239,6 +1254,74 @@ async function fetchWaybackSnapshot(
1239
1254
  if (!snapResult || snapResult.error !== null) return null;
1240
1255
  return { body: snapResult.body, contentType: snapResult.contentType, timestamp };
1241
1256
  }
1257
+ const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
1258
+
1259
+ function parseLocalPath(url: string): string | null {
1260
+ const trimmed = url.trim();
1261
+ if (/^file:/i.test(trimmed)) {
1262
+ try {
1263
+ return fileURLToPath(trimmed);
1264
+ } catch {
1265
+ return "";
1266
+ }
1267
+ }
1268
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return join(homedir(), trimmed.slice(2));
1269
+ if (WINDOWS_PATH_RE.test(trimmed) || trimmed.startsWith("/") || /^\.{1,2}\//.test(trimmed)) return trimmed;
1270
+ return null;
1271
+ }
1272
+
1273
+ function localFileFailure(err: unknown): string {
1274
+ return `Failed to read file: ${err instanceof Error ? err.message : String(err)}`;
1275
+ }
1276
+
1277
+ async function readLocalFile(
1278
+ filePath: string,
1279
+ options: { signal?: AbortSignal; maxChars?: number; maxBytes?: number; maxPdfBytes?: number },
1280
+ ): Promise<string> {
1281
+ if (options.signal?.aborted) return "Failed to read file: cancelled.";
1282
+ let handle: FileHandle;
1283
+ try {
1284
+ handle = await open(filePath, "r");
1285
+ } catch (err) {
1286
+ return localFileFailure(err);
1287
+ }
1288
+ try {
1289
+ const stats = await handle.stat();
1290
+ if (stats.isDirectory()) return `Failed to read file: ${filePath} is a directory.`;
1291
+ const maxBytes = options.maxBytes ?? MAX_FETCH_BYTES;
1292
+ const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
1293
+ const length = Math.min(stats.size, Math.max(maxBytes, maxPdfBytes));
1294
+ const buffer = Buffer.alloc(Number(length));
1295
+ let read = 0;
1296
+ while (read < length) {
1297
+ const { bytesRead } = await handle.read(buffer, read, length - read, read);
1298
+ if (!bytesRead) break;
1299
+ read += bytesRead;
1300
+ }
1301
+ if (options.signal?.aborted) return "Failed to read file: cancelled.";
1302
+ const head = read < length ? buffer.subarray(0, read) : buffer;
1303
+ const isPdf = hasPdfMagic(head);
1304
+ const limit = isPdf ? maxPdfBytes : maxBytes;
1305
+ const truncated = stats.size > limit;
1306
+ const body = head.length > limit ? head.subarray(0, limit) : head;
1307
+ if (isPdf) {
1308
+ let pdfText: string;
1309
+ try {
1310
+ pdfText = await extractPdfText(body);
1311
+ } catch {
1312
+ return withTruncation("(PDF content could not be read as text)", truncated);
1313
+ }
1314
+ if (!pdfText) pdfText = "(PDF contains no extractable text)";
1315
+ return truncatePageText(withTruncation(pdfText, truncated), options.maxChars);
1316
+ }
1317
+ const text = decodeWithCodec(body, bomCodecFor(body) ?? "utf-8");
1318
+ if (looksBinary(text)) return `(binary content, ${body.length} bytes; not readable as text)`;
1319
+ return truncatePageText(withTruncation(renderBody(text, ""), truncated), options.maxChars);
1320
+ } finally {
1321
+ await handle.close().catch(() => {});
1322
+ }
1323
+ }
1324
+
1242
1325
  export async function fetchPageText(
1243
1326
  url: string,
1244
1327
  options: FetchPageOptions = {},
@@ -1250,6 +1333,18 @@ export async function fetchPageText(
1250
1333
  const policy = options.websitePolicy ?? null;
1251
1334
  const maxChars = options.maxChars;
1252
1335
  const rawFetch = options.rawFetch ?? fetchUrlRaw;
1336
+ if (options.allowLocalFiles !== false) {
1337
+ const localPath = parseLocalPath(url);
1338
+ if (localPath !== null) {
1339
+ if (!localPath) return "Failed to read file: invalid file URL.";
1340
+ return readLocalFile(localPath, {
1341
+ signal,
1342
+ maxChars,
1343
+ maxBytes: options.maxBytes,
1344
+ maxPdfBytes: options.maxPdfBytes,
1345
+ });
1346
+ }
1347
+ }
1253
1348
  url = normalizeUrlScheme(url);
1254
1349
  const [allowed, reason] = checkUrlAccess(url, policy);
1255
1350
  if (!allowed) return reason;
@@ -1257,6 +1352,7 @@ export async function fetchPageText(
1257
1352
  deadlineMs,
1258
1353
  signal,
1259
1354
  websitePolicy: policy,
1355
+ allowPrivateAddresses: options.allowPrivateAddresses,
1260
1356
  maxBytes: options.maxBytes,
1261
1357
  maxPdfBytes: options.maxPdfBytes,
1262
1358
  seams: options.seams,