Skip to content

Commit 76f5860

Browse files
authored
Merge pull request #6 from dnacenta/release/v0.3.0
release: v0.3.0 — web_fetch tool
2 parents f570b6b + 0487044 commit 76f5860

4 files changed

Lines changed: 300 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "echo-system"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
rust-version = "1.80"
66
license = "AGPL-3.0-only"

src/server/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub async fn start(config: Config) -> Result<(), Box<dyn std::error::Error>> {
5454
tools.register(Box::new(crate::tools::file_list::FileListTool::new(
5555
root_dir.clone(),
5656
)));
57+
tools.register(Box::new(crate::tools::web_fetch::WebFetchTool::new()));
5758
tracing::info!("Registered {} built-in tool(s)", tools.definitions().len());
5859

5960
// Initialize and start plugins

src/tools/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod file_list;
22
pub mod file_read;
33
pub mod file_write;
4+
pub mod web_fetch;
45

56
use std::fmt;
67
use std::future::Future;

src/tools/web_fetch.rs

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
use std::net::IpAddr;
2+
3+
use super::{Tool, ToolError, ToolResult};
4+
5+
/// Fetch content from a public URL.
6+
pub struct WebFetchTool {
7+
client: reqwest::Client,
8+
}
9+
10+
const MAX_RESPONSE_BYTES: usize = 1_024 * 1_024; // 1MB
11+
const TIMEOUT_SECS: u64 = 30;
12+
13+
impl WebFetchTool {
14+
pub fn new() -> Self {
15+
let client = reqwest::Client::builder()
16+
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
17+
.redirect(reqwest::redirect::Policy::limited(5))
18+
.build()
19+
.expect("Failed to build HTTP client");
20+
Self { client }
21+
}
22+
}
23+
24+
/// Check if a URL targets a private/loopback address.
25+
fn is_private_url(url: &reqwest::Url) -> bool {
26+
if let Some(host) = url.host_str() {
27+
// Block localhost variants
28+
if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "0.0.0.0" {
29+
return true;
30+
}
31+
// Block private IP ranges
32+
if let Ok(ip) = host.parse::<IpAddr>() {
33+
return match ip {
34+
IpAddr::V4(v4) => {
35+
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
36+
}
37+
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified(),
38+
};
39+
}
40+
// Block metadata endpoints
41+
if host == "169.254.169.254" || host.ends_with(".internal") {
42+
return true;
43+
}
44+
}
45+
true // No host = blocked
46+
}
47+
48+
/// Naive HTML to text: strip tags, decode common entities, collapse whitespace.
49+
fn html_to_text(html: &str) -> String {
50+
let mut out = String::with_capacity(html.len());
51+
let mut in_tag = false;
52+
let mut in_script = false;
53+
let mut in_style = false;
54+
let mut last_was_space = false;
55+
56+
let lower = html.to_lowercase();
57+
let chars: Vec<char> = html.chars().collect();
58+
let lower_chars: Vec<char> = lower.chars().collect();
59+
let len = chars.len();
60+
let mut i = 0;
61+
62+
while i < len {
63+
if !in_tag && lower_chars[i..].starts_with(&['<', 's', 'c', 'r', 'i', 'p', 't']) {
64+
in_script = true;
65+
}
66+
if !in_tag && lower_chars[i..].starts_with(&['<', 's', 't', 'y', 'l', 'e']) {
67+
in_style = true;
68+
}
69+
if in_script && lower_chars[i..].starts_with(&['<', '/', 's', 'c', 'r', 'i', 'p', 't', '>'])
70+
{
71+
in_script = false;
72+
i += 9;
73+
continue;
74+
}
75+
if in_style && lower_chars[i..].starts_with(&['<', '/', 's', 't', 'y', 'l', 'e', '>']) {
76+
in_style = false;
77+
i += 8;
78+
continue;
79+
}
80+
81+
if in_script || in_style {
82+
i += 1;
83+
continue;
84+
}
85+
86+
if chars[i] == '<' {
87+
// Add newline for block-level tags
88+
if lower_chars[i..].starts_with(&['<', 'p'])
89+
|| lower_chars[i..].starts_with(&['<', 'b', 'r'])
90+
|| lower_chars[i..].starts_with(&['<', 'd', 'i', 'v'])
91+
|| lower_chars[i..].starts_with(&['<', 'h'])
92+
|| lower_chars[i..].starts_with(&['<', 'l', 'i'])
93+
|| lower_chars[i..].starts_with(&['<', 't', 'r'])
94+
{
95+
if !out.ends_with('\n') {
96+
out.push('\n');
97+
}
98+
last_was_space = true;
99+
}
100+
in_tag = true;
101+
i += 1;
102+
continue;
103+
}
104+
105+
if chars[i] == '>' {
106+
in_tag = false;
107+
i += 1;
108+
continue;
109+
}
110+
111+
if in_tag {
112+
i += 1;
113+
continue;
114+
}
115+
116+
// Decode HTML entities
117+
if chars[i] == '&' {
118+
if lower_chars[i..].starts_with(&['&', 'a', 'm', 'p', ';']) {
119+
out.push('&');
120+
i += 5;
121+
last_was_space = false;
122+
continue;
123+
} else if lower_chars[i..].starts_with(&['&', 'l', 't', ';']) {
124+
out.push('<');
125+
i += 4;
126+
last_was_space = false;
127+
continue;
128+
} else if lower_chars[i..].starts_with(&['&', 'g', 't', ';']) {
129+
out.push('>');
130+
i += 4;
131+
last_was_space = false;
132+
continue;
133+
} else if lower_chars[i..].starts_with(&['&', 'q', 'u', 'o', 't', ';']) {
134+
out.push('"');
135+
i += 6;
136+
last_was_space = false;
137+
continue;
138+
} else if lower_chars[i..].starts_with(&['&', 'n', 'b', 's', 'p', ';']) {
139+
out.push(' ');
140+
i += 6;
141+
last_was_space = true;
142+
continue;
143+
} else if lower_chars[i..].starts_with(&['&', '#', '3', '9', ';']) {
144+
out.push('\'');
145+
i += 5;
146+
last_was_space = false;
147+
continue;
148+
} else if lower_chars[i..].starts_with(&['&', 'a', 'p', 'o', 's', ';']) {
149+
out.push('\'');
150+
i += 6;
151+
last_was_space = false;
152+
continue;
153+
}
154+
}
155+
156+
// Collapse whitespace
157+
if chars[i].is_whitespace() {
158+
if !last_was_space {
159+
out.push(' ');
160+
last_was_space = true;
161+
}
162+
i += 1;
163+
continue;
164+
}
165+
166+
out.push(chars[i]);
167+
last_was_space = false;
168+
i += 1;
169+
}
170+
171+
// Clean up excessive blank lines
172+
let mut result = String::new();
173+
let mut blank_count = 0;
174+
for line in out.lines() {
175+
let trimmed = line.trim();
176+
if trimmed.is_empty() {
177+
blank_count += 1;
178+
if blank_count <= 2 {
179+
result.push('\n');
180+
}
181+
} else {
182+
blank_count = 0;
183+
result.push_str(trimmed);
184+
result.push('\n');
185+
}
186+
}
187+
188+
result.trim().to_string()
189+
}
190+
191+
impl Tool for WebFetchTool {
192+
fn name(&self) -> &str {
193+
"web_fetch"
194+
}
195+
196+
fn description(&self) -> &str {
197+
"Fetch content from a public URL. Returns the page text. HTTPS only, no private/local addresses."
198+
}
199+
200+
fn input_schema(&self) -> serde_json::Value {
201+
serde_json::json!({
202+
"type": "object",
203+
"properties": {
204+
"url": {
205+
"type": "string",
206+
"description": "The URL to fetch (must be HTTPS)"
207+
}
208+
},
209+
"required": ["url"]
210+
})
211+
}
212+
213+
fn execute(&self, input: serde_json::Value) -> ToolResult<'_> {
214+
Box::pin(async move {
215+
let url_str = input["url"]
216+
.as_str()
217+
.ok_or_else(|| ToolError::ExecutionFailed("Missing 'url' parameter".to_string()))?;
218+
219+
// Parse and validate URL
220+
let url: reqwest::Url = url_str
221+
.parse()
222+
.map_err(|e| ToolError::ExecutionFailed(format!("Invalid URL: {}", e)))?;
223+
224+
// HTTPS only
225+
if url.scheme() != "https" {
226+
return Err(ToolError::PermissionDenied(
227+
"Only HTTPS URLs are allowed".to_string(),
228+
));
229+
}
230+
231+
// Block private/loopback addresses
232+
if is_private_url(&url) {
233+
return Err(ToolError::PermissionDenied(
234+
"URLs targeting private or local addresses are not allowed".to_string(),
235+
));
236+
}
237+
238+
// Fetch
239+
let response = self
240+
.client
241+
.get(url)
242+
.header("User-Agent", "echo-system/0.2.0")
243+
.send()
244+
.await
245+
.map_err(|e| ToolError::ExecutionFailed(format!("Request failed: {}", e)))?;
246+
247+
let status = response.status();
248+
if !status.is_success() {
249+
return Err(ToolError::ExecutionFailed(format!(
250+
"HTTP {} {}",
251+
status.as_u16(),
252+
status.canonical_reason().unwrap_or("Unknown")
253+
)));
254+
}
255+
256+
// Check content length before downloading
257+
if let Some(len) = response.content_length() {
258+
if len as usize > MAX_RESPONSE_BYTES {
259+
return Err(ToolError::ExecutionFailed(format!(
260+
"Response too large: {} bytes (max {})",
261+
len, MAX_RESPONSE_BYTES
262+
)));
263+
}
264+
}
265+
266+
// Read body with size limit
267+
let content_type = response
268+
.headers()
269+
.get("content-type")
270+
.and_then(|v| v.to_str().ok())
271+
.unwrap_or("")
272+
.to_lowercase();
273+
274+
let bytes = response
275+
.bytes()
276+
.await
277+
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read body: {}", e)))?;
278+
279+
if bytes.len() > MAX_RESPONSE_BYTES {
280+
return Err(ToolError::ExecutionFailed(format!(
281+
"Response too large: {} bytes (max {})",
282+
bytes.len(),
283+
MAX_RESPONSE_BYTES
284+
)));
285+
}
286+
287+
let text = String::from_utf8_lossy(&bytes).to_string();
288+
289+
// Convert HTML to plain text, pass through other content types
290+
if content_type.contains("text/html") {
291+
Ok(html_to_text(&text))
292+
} else {
293+
Ok(text)
294+
}
295+
})
296+
}
297+
}

0 commit comments

Comments
 (0)