Skip to content

Commit 26ff36a

Browse files
joaomdmouraclaude
andcommitted
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>
1 parent 319a20c commit 26ff36a

9 files changed

Lines changed: 1038 additions & 18 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: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

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

@@ -24,15 +23,19 @@ def _is_url(path: str) -> bool:
2423
return False
2524

2625
@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.
26+
def _fetch_from_url(url: str, kwargs: dict[str, Any]) -> bytes:
27+
"""Download a PDF from a URL and return its bytes.
28+
29+
The content stays in memory rather than going to a temporary file: the
30+
whole body has to be buffered either way, and a temp file would need
31+
unlinking on every error path to avoid leaving files behind.
2932
3033
Args:
3134
url: The URL to download from.
3235
kwargs: Optional dict that may contain custom headers.
3336
3437
Returns:
35-
Path to the temporary file containing the PDF.
38+
The raw PDF content.
3639
3740
Raises:
3841
ValueError: If the download fails.
@@ -48,10 +51,7 @@ def _download_from_url(url: str, kwargs: dict[str, Any]) -> str:
4851
try:
4952
response = safe_get(url, headers=headers, timeout=30)
5053
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
54+
return response.content
5555
except Exception as e:
5656
raise ValueError(f"Failed to download PDF from {url}: {e!s}") from e
5757

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

9494
try:
9595
if is_url:
96-
local_path = self._download_from_url(file_path, kwargs)
97-
doc = pymupdf.open(local_path)
96+
doc = pymupdf.open(
97+
stream=self._fetch_from_url(file_path, kwargs), filetype="pdf"
98+
)
9899
else:
99100
if not os.path.isfile(file_path):
100101
raise FileNotFoundError(f"PDF file not found: {file_path}")
101102
doc = pymupdf.open(file_path)
102103

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()
104+
# Closed in a finally so a failure mid-extraction still releases the
105+
# document handle.
106+
try:
107+
metadata["num_pages"] = len(doc)
108+
109+
for page_num, page in enumerate(doc, 1):
110+
page_text = page.get_text()
111+
if page_text.strip():
112+
text_content.append(f"Page {page_num}:\n{page_text}")
113+
finally:
114+
doc.close()
111115
except FileNotFoundError:
112116
raise
113117
except Exception as e:

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

Lines changed: 68 additions & 0 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",
@@ -86,3 +87,70 @@ def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Re
8687
history.append(response)
8788
current_url = redirect_url
8889
redirects_followed += 1
90+
91+
92+
def safe_get_bounded(
93+
url: str,
94+
*,
95+
max_bytes: int,
96+
timeout: float | tuple[float, float] = 30,
97+
headers: dict[str, str] | None = None,
98+
max_redirects: int = 10,
99+
) -> tuple[bytes, str, str]:
100+
"""GET a URL through :func:`safe_get`, refusing bodies over *max_bytes*.
101+
102+
The body is streamed and abandoned as soon as it crosses the limit, so an
103+
oversized response costs one chunk of memory instead of all of it. The cap
104+
counts decoded bytes, which is what a compressed response expands into --
105+
``Content-Length`` describes the wire size and cannot bound that.
106+
107+
Args:
108+
url: The URL to fetch.
109+
max_bytes: Largest body to accept, in decoded bytes.
110+
timeout: Request timeout, passed through to requests.
111+
headers: Request headers.
112+
max_redirects: Hops to follow before giving up.
113+
114+
Returns:
115+
A ``(body, content_type, final_url)`` tuple, where *final_url* is the
116+
last validated URL in the redirect chain.
117+
118+
Raises:
119+
ValueError: If URL validation fails, the redirect chain is too long, or
120+
the body exceeds *max_bytes*.
121+
requests.RequestException: If the request fails or returns an error
122+
status.
123+
"""
124+
response = safe_get(
125+
url,
126+
max_redirects=max_redirects,
127+
headers=headers,
128+
timeout=timeout,
129+
stream=True,
130+
)
131+
try:
132+
response.raise_for_status()
133+
134+
chunks: list[bytes] = []
135+
total = 0
136+
for chunk in response.iter_content(chunk_size=_STREAM_CHUNK_SIZE):
137+
if not chunk:
138+
continue
139+
total += len(chunk)
140+
if total > max_bytes:
141+
raise ValueError(
142+
f"Response body from '{url}' exceeds the {max_bytes} byte limit."
143+
)
144+
chunks.append(chunk)
145+
146+
return (
147+
b"".join(chunks),
148+
response.headers.get("Content-Type", ""),
149+
response.url,
150+
)
151+
finally:
152+
# Under stream=True each hop holds its connection until the body is read,
153+
# so the redirects need closing too, not just the response we return.
154+
for hop in response.history:
155+
hop.close()
156+
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)