Skip to content

Commit 0c74f23

Browse files
joaomdmouraclaude
andauthored
feat(tools): add URLReadTool for reading arbitrary URLs (#6834)
* feat(tools): add URLReadTool for reading arbitrary URLs FileReadTool is confined to the local filesystem, so there was no way for an agent to read a document that lives behind an http(s) URL. Rather than adding a flag to FileReadTool, this adds a separate tool: granting it grants network egress to addresses an LLM picks at runtime, and that should be a deliberate choice rather than a toggle on a filesystem tool. URLReadTool fetches a URL and returns its content as text. PDF and DOCX bodies have their text extracted, HTML is stripped to visible text, and text-shaped types (plain text, Markdown, JSON, XML, YAML, CSV) are decoded using the charset the server declares. Any other content type is refused rather than returned as base64, keeping the output text-only. Requests reuse the existing SSRF protections in security/safe_requests: validate_url resolves every hostname and rejects private, loopback, link-local and reserved addresses (covering cloud metadata endpoints), and safe_get never auto-follows redirects, revalidating each hop and dropping credentials on cross-origin ones. Resolving before validating also normalizes encoded forms, so http://2130706433/ is rejected as 127.0.0.1 without needing a string blocklist. Adds safe_get_bounded on top of that, which streams the body and abandons it once it crosses max_bytes. The cap counts decoded bytes, which is what a compressed response expands into -- Content-Length describes the wire size and cannot bound that. It also closes the redirect hops, which stream=True would otherwise leave holding their connections. Two risks are documented rather than closed. Validation resolves the hostname and requests resolves it again to connect, so DNS rebinding remains possible; closing it needs the connection pinned to the validated address, which would change behavior for all existing safe_get callers. And the returned text is untrusted remote content entering an agent's context, which input validation cannot address. Also fixes a temp file leak in PDFLoader, which reached the same pymupdf-from-URL path. It wrote downloads to NamedTemporaryFile with delete=False and never unlinked them, so every PDF ingested from a URL left a file behind. It now opens from memory, the way URLReadTool does, which removes the leak by construction instead of relying on cleanup on each error path; its doc.close() also moves into a finally so a failure mid-extraction still releases the handle. PDFLoader had no test file, so this adds one covering both paths plus a regression test asserting no temp file is created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): address review feedback on URLReadTool Bound start_line and line_count with ge=1 in the args schema. Both were unbounded, and _window computes stop as start + line_count, so a negative line_count reached islice, which rejects a negative stop. The windowing runs outside the tool's error handling, so that escaped _run as a raw ValueError instead of an error string. BaseTool.run validates kwargs against args_schema, so the constraint refuses the value before any request is made. _window also clamps its own bounds now, so it cannot raise if called directly. Bound the PDFLoader download with safe_get_bounded. The body is held in memory for the whole extraction, so it needed a ceiling; it defaults to 50 MiB and takes a max_bytes kwarg to load() for callers ingesting larger documents. Patch the loader's own seam in its tests rather than requests.get. Both safe_get and safe_get_bounded resolve the hostname before requesting, so the previous mocks made the tests depend on DNS for example.com and fail in a network-isolated runner for reasons unrelated to the loader. Exercise the content-type fallback through run() rather than asserting on _resolve_kind, so the tests survive a refactor of the classification internals, and cover the octet-stream PDF, missing-type, query-string and unknown-extension cases as observable behavior. Add docstrings to the new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): close streamed redirect hops and widen type fallback Four findings from the Copilot and Cursor reviews. safe_get leaked its accumulated hops on every failure path. It closed the response it was about to abandon but not the ones already in history, and a caller handed an exception has no handle on them -- under stream=True each holds its connection until its body is read or closed. The loop now closes history before re-raising. Hops are still the caller's on success, where they arrive via response.history. safe_get_bounded rejected a non-positive max_bytes only after issuing the request, and then reported it as an oversized body. It now fails before the request. Its oversized-body error also named the requested URL rather than the one that served the body, which differ after a redirect. The content-type fallback consulted only the final URL for an extension, so a .pdf link redirecting to an extensionless CDN or presigned path was refused even though the requested URL identified the type. It now checks the final URL first, then the requested one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 53def96 commit 0c74f23

10 files changed

Lines changed: 1328 additions & 54 deletions

File tree

lib/crewai-tools/src/crewai_tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@
209209
)
210210
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
211211
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
212+
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
212213
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
213214
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
214215
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
@@ -327,6 +328,7 @@
327328
"TavilyGetResearchTool",
328329
"TavilyResearchTool",
329330
"TavilySearchTool",
331+
"URLReadTool",
330332
"VisionTool",
331333
"WaitTool",
332334
"WeaviateVectorSearchTool",

lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
import os
44
from pathlib import Path
5-
import tempfile
65
from typing import Any
76
from urllib.parse import urlparse
87

98
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
109
from crewai_tools.rag.source_content import SourceContent
11-
from crewai_tools.security.safe_requests import safe_get
10+
from crewai_tools.security.safe_requests import safe_get_bounded
11+
12+
13+
# Remote PDFs are held in memory for the whole extraction, so the download needs
14+
# a ceiling. Override per call with the ``max_bytes`` kwarg to ``load``.
15+
DEFAULT_MAX_PDF_BYTES = 50 * 1024 * 1024
1216

1317

1418
class PDFLoader(BaseLoader):
@@ -24,18 +28,24 @@ def _is_url(path: str) -> bool:
2428
return False
2529

2630
@staticmethod
27-
def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
28-
"""Download PDF from a URL to a temporary file and return its path.
31+
def _fetch_from_url(url: str, kwargs: dict[str, Any]) -> bytes:
32+
"""Download a PDF from a URL and return its bytes.
33+
34+
The content stays in memory rather than going to a temporary file: the
35+
whole body has to be buffered either way, and a temp file would need
36+
unlinking on every error path to avoid leaving files behind. Because it
37+
is held in memory, the download is capped.
2938
3039
Args:
3140
url: The URL to download from.
32-
kwargs: Optional dict that may contain custom headers.
41+
kwargs: Optional dict that may contain custom ``headers`` and a
42+
``max_bytes`` ceiling for the download.
3343
3444
Returns:
35-
Path to the temporary file containing the PDF.
45+
The raw PDF content.
3646
3747
Raises:
38-
ValueError: If the download fails.
48+
ValueError: If the download fails or exceeds the size ceiling.
3949
"""
4050
headers = kwargs.get(
4151
"headers",
@@ -46,12 +56,13 @@ def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
4656
)
4757

4858
try:
49-
response = safe_get(url, headers=headers, timeout=30)
50-
response.raise_for_status()
51-
52-
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:
53-
temp_file.write(response.content)
54-
return temp_file.name
59+
body, _content_type, _final_url = safe_get_bounded(
60+
url,
61+
max_bytes=kwargs.get("max_bytes", DEFAULT_MAX_PDF_BYTES),
62+
headers=headers,
63+
timeout=30,
64+
)
65+
return body
5566
except Exception as e:
5667
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
5768

@@ -93,21 +104,25 @@ def load(self, source: SourceContent, **kwargs: Any) -> LoaderResult: # type: i
93104

94105
try:
95106
if is_url:
96-
local_path = self._download_from_url(file_path, kwargs)
97-
doc = pymupdf.open(local_path)
107+
doc = pymupdf.open(
108+
stream=self._fetch_from_url(file_path, kwargs), filetype="pdf"
109+
)
98110
else:
99111
if not os.path.isfile(file_path):
100112
raise FileNotFoundError(f"PDF file not found: {file_path}")
101113
doc = pymupdf.open(file_path)
102114

103-
metadata["num_pages"] = len(doc)
104-
105-
for page_num, page in enumerate(doc, 1):
106-
page_text = page.get_text()
107-
if page_text.strip():
108-
text_content.append(f"Page {page_num}:\n{page_text}")
109-
110-
doc.close()
115+
# Closed in a finally so a failure mid-extraction still releases the
116+
# document handle.
117+
try:
118+
metadata["num_pages"] = len(doc)
119+
120+
for page_num, page in enumerate(doc, 1):
121+
page_text = page.get_text()
122+
if page_text.strip():
123+
text_content.append(f"Page {page_num}:\n{page_text}")
124+
finally:
125+
doc.close()
111126
except FileNotFoundError:
112127
raise
113128
except Exception as e:

lib/crewai-tools/src/crewai_tools/security/safe_requests.py

Lines changed: 116 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212

1313
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
14+
_STREAM_CHUNK_SIZE = 65536
1415
_SENSITIVE_HEADER_NAMES = {
1516
"authorization",
1617
"cookie",
@@ -49,40 +50,124 @@ def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str,
4950

5051

5152
def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response:
52-
"""GET a URL while validating each redirect target before following it."""
53+
"""GET a URL while validating each redirect target before following it.
54+
55+
On success the hops are attached to the returned response's ``history`` and
56+
are the caller's to close. On failure they are closed here: a caller given
57+
an exception has no handle on them, and a streamed hop holds its connection
58+
until its body is read or closed.
59+
"""
5360
current_url = validate_url(url)
5461
request_kwargs = {**kwargs, "allow_redirects": False}
5562
timeout = request_kwargs.pop("timeout", 30)
5663
history: list[requests.Response] = []
5764
redirects_followed = 0
5865

59-
while True:
60-
response = requests.get(current_url, timeout=timeout, **request_kwargs)
61-
if (
62-
response.status_code not in _REDIRECT_STATUS_CODES
63-
or "Location" not in response.headers
64-
):
65-
response.history = history
66-
return response
67-
68-
if redirects_followed >= max_redirects:
69-
response.close()
70-
raise ValueError(f"Too many redirects while fetching URL: {url}")
71-
72-
location = response.headers.get("Location")
73-
if not location:
74-
response.history = history
75-
return response
76-
77-
try:
78-
redirect_url = validate_url(urljoin(response.url, location))
79-
except ValueError:
80-
response.close()
81-
raise
82-
83-
if not _same_origin(current_url, redirect_url):
84-
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
85-
86-
history.append(response)
87-
current_url = redirect_url
88-
redirects_followed += 1
66+
try:
67+
while True:
68+
response = requests.get(current_url, timeout=timeout, **request_kwargs)
69+
if (
70+
response.status_code not in _REDIRECT_STATUS_CODES
71+
or "Location" not in response.headers
72+
):
73+
response.history = history
74+
return response
75+
76+
if redirects_followed >= max_redirects:
77+
response.close()
78+
raise ValueError(f"Too many redirects while fetching URL: {url}")
79+
80+
location = response.headers.get("Location")
81+
if not location:
82+
response.history = history
83+
return response
84+
85+
try:
86+
redirect_url = validate_url(urljoin(response.url, location))
87+
except ValueError:
88+
response.close()
89+
raise
90+
91+
if not _same_origin(current_url, redirect_url):
92+
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
93+
94+
history.append(response)
95+
current_url = redirect_url
96+
redirects_followed += 1
97+
except BaseException:
98+
for hop in history:
99+
hop.close()
100+
raise
101+
102+
103+
def safe_get_bounded(
104+
url: str,
105+
*,
106+
max_bytes: int,
107+
timeout: float | tuple[float, float] = 30,
108+
headers: dict[str, str] | None = None,
109+
max_redirects: int = 10,
110+
) -> tuple[bytes, str, str]:
111+
"""GET a URL through :func:`safe_get`, refusing bodies over *max_bytes*.
112+
113+
The body is streamed and abandoned as soon as it crosses the limit, so an
114+
oversized response costs one chunk of memory instead of all of it. The cap
115+
counts decoded bytes, which is what a compressed response expands into --
116+
``Content-Length`` describes the wire size and cannot bound that.
117+
118+
Args:
119+
url: The URL to fetch.
120+
max_bytes: Largest body to accept, in decoded bytes.
121+
timeout: Request timeout, passed through to requests.
122+
headers: Request headers.
123+
max_redirects: Hops to follow before giving up.
124+
125+
Returns:
126+
A ``(body, content_type, final_url)`` tuple, where *final_url* is the
127+
last validated URL in the redirect chain.
128+
129+
Raises:
130+
ValueError: If *max_bytes* is not positive, URL validation fails, the
131+
redirect chain is too long, or the body exceeds *max_bytes*.
132+
requests.RequestException: If the request fails or returns an error
133+
status.
134+
"""
135+
if max_bytes <= 0:
136+
raise ValueError(f"max_bytes must be positive, got {max_bytes}.")
137+
138+
response = safe_get(
139+
url,
140+
max_redirects=max_redirects,
141+
headers=headers,
142+
timeout=timeout,
143+
stream=True,
144+
)
145+
try:
146+
response.raise_for_status()
147+
148+
chunks: list[bytes] = []
149+
total = 0
150+
for chunk in response.iter_content(chunk_size=_STREAM_CHUNK_SIZE):
151+
if not chunk:
152+
continue
153+
total += len(chunk)
154+
if total > max_bytes:
155+
# Names the URL that served the body, which after a redirect is
156+
# not the one that was requested.
157+
raise ValueError(
158+
f"Response body from '{response.url}' exceeds the "
159+
f"{max_bytes} byte limit."
160+
)
161+
chunks.append(chunk)
162+
163+
return (
164+
b"".join(chunks),
165+
response.headers.get("Content-Type", ""),
166+
response.url,
167+
)
168+
finally:
169+
# Under stream=True each hop holds its connection until the body is read,
170+
# so the redirects need closing too, not just the response we return.
171+
for hop in response.history:
172+
hop.close()
173+
response.close()

lib/crewai-tools/src/crewai_tools/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
)
197197
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
198198
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
199+
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
199200
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
200201
from crewai_tools.tools.wait_tool.wait_tool import WaitTool
201202
from crewai_tools.tools.weaviate_tool.vector_search import WeaviateVectorSearchTool
@@ -310,6 +311,7 @@
310311
"TavilyGetResearchTool",
311312
"TavilyResearchTool",
312313
"TavilySearchTool",
314+
"URLReadTool",
313315
"VisionTool",
314316
"WaitTool",
315317
"WeaviateVectorSearchTool",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from crewai_tools.tools.url_read_tool.url_read_tool import URLReadTool
2+
3+
4+
__all__ = ["URLReadTool"]

0 commit comments

Comments
 (0)