1use async_trait::async_trait;
2use serde_json::json;
3
4use super::{Tool, ToolCtx, ToolResult, resolve_workspace_path};
5use sparrow_core::event::{Block, RiskLevel};
6
7pub struct Search;
10
11#[async_trait]
12impl Tool for Search {
13 fn name(&self) -> &str {
14 "search"
15 }
16 fn description(&self) -> &str {
17 "Search code in the workspace using ripgrep (regex patterns)"
18 }
19 fn schema(&self) -> serde_json::Value {
20 json!({
21 "type": "object",
22 "properties": {
23 "pattern": { "type": "string", "description": "Regex pattern to search for" },
24 "path": { "type": "string", "description": "Directory or file to search (default: workspace root)" },
25 "include": { "type": "string", "description": "File pattern filter (e.g. '*.rs')" },
26 "max_results": { "type": "integer", "description": "Max results (default: 50)" }
27 },
28 "required": ["pattern"]
29 })
30 }
31 fn risk(&self) -> RiskLevel {
32 RiskLevel::ReadOnly
33 }
34 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
35 let pattern = args["pattern"].as_str().unwrap_or("");
36 let path = args["path"].as_str().unwrap_or(".");
37 let include = args["include"].as_str();
38 let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
39
40 let search_path = resolve_workspace_path(&ctx.workspace_root, path)?;
41
42 let mut cmd = std::process::Command::new("rg");
43 cmd.arg("--line-number")
44 .arg("--no-heading")
45 .arg("--color=never")
46 .arg("-M")
47 .arg(max_results.to_string())
48 .arg(pattern)
49 .arg(&search_path);
50
51 if let Some(inc) = include {
52 cmd.arg("--glob").arg(inc);
53 }
54
55 match cmd.output() {
56 Ok(output) => {
57 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
58 if stdout.is_empty() {
59 Ok(ToolResult::text("No matches found."))
60 } else {
61 Ok(ToolResult::text(stdout))
62 }
63 }
64 Err(e) => {
65 if e.kind() == std::io::ErrorKind::NotFound {
67 let mut results = Vec::new();
68 basic_grep(&search_path, pattern, include, &mut results, 0, max_results)?;
69 if results.is_empty() {
70 Ok(ToolResult::text(
71 "No matches found (rg not installed, used basic search).",
72 ))
73 } else {
74 Ok(ToolResult::text(results.join("\n")))
75 }
76 } else {
77 Err(e.into())
78 }
79 }
80 }
81 }
82}
83
84fn basic_grep(
85 dir: &std::path::Path,
86 pattern: &str,
87 include: Option<&str>,
88 results: &mut Vec<String>,
89 depth: usize,
90 max: usize,
91) -> std::io::Result<()> {
92 if depth > 10 || results.len() >= max {
93 return Ok(());
94 }
95 if dir.is_dir() {
96 let entries = std::fs::read_dir(dir)?;
97 for entry in entries.flatten() {
98 let path = entry.path();
99 let name = path.file_name().unwrap_or_default().to_string_lossy();
100 if name.starts_with('.') || name == "target" || name == "node_modules" {
101 continue;
102 }
103 if path.is_dir() {
104 basic_grep(&path, pattern, include, results, depth + 1, max)?;
105 } else if path.is_file() {
106 if let Some(inc) = include {
107 if !name.contains(inc) && !inc.contains('*') {
108 continue;
109 }
110 }
111 if results.len() >= max {
112 break;
113 }
114 if let Ok(content) = std::fs::read_to_string(&path) {
115 for (i, line) in content.lines().enumerate() {
116 if results.len() >= max {
117 break;
118 }
119 if line.to_lowercase().contains(&pattern.to_lowercase()) {
120 let rel = path.strip_prefix(dir).unwrap_or(&path).display();
121 results.push(format!("{}:{}: {}", rel, i + 1, line.trim()));
122 }
123 }
124 }
125 }
126 }
127 }
128 Ok(())
129}
130
131pub struct WebSearch;
134
135#[async_trait]
136impl Tool for WebSearch {
137 fn name(&self) -> &str {
138 "web_search"
139 }
140 fn description(&self) -> &str {
141 "Search the web for information"
142 }
143 fn schema(&self) -> serde_json::Value {
144 json!({
145 "type": "object",
146 "properties": {
147 "query": { "type": "string", "description": "Search query" },
148 "num_results": { "type": "integer", "description": "Number of results (default: 5)" }
149 },
150 "required": ["query"]
151 })
152 }
153 fn risk(&self) -> RiskLevel {
154 RiskLevel::Network
155 }
156 async fn call(&self, args: serde_json::Value, _ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
157 let query = args["query"].as_str().unwrap_or("");
158 let num = args["num_results"].as_u64().unwrap_or(5);
159
160 let client = reqwest::Client::builder()
161 .user_agent("sparrow/0.1")
162 .build()?;
163
164 let resp = client
166 .get("https://lite.duckduckgo.com/lite/")
167 .query(&[("q", query)])
168 .send()
169 .await?;
170
171 let html = resp.text().await?;
172
173 let mut results = Vec::new();
175 for line in html.lines() {
176 let trimmed = line.trim();
177 if trimmed.starts_with("<a") && trimmed.contains("class=\"result-link\"") {
178 if let Some(url) = extract_href(trimmed) {
179 if results.len() < num as usize {
180 results.push(format!("🔗 {}", url));
181 }
182 }
183 }
184 if trimmed.starts_with("<td") && trimmed.contains("class=\"result-snippet\"") {
185 let snippet = strip_html(trimmed);
186 if !snippet.is_empty() && results.len() <= num as usize {
187 results.push(format!(" {}", snippet));
188 }
189 }
190 }
191
192 if results.is_empty() {
193 Ok(ToolResult::text(format!(
194 "No web results for: {}. Try a more specific query.",
195 query
196 )))
197 } else {
198 Ok(ToolResult::text(results.join("\n")))
199 }
200 }
201}
202
203fn extract_href(line: &str) -> Option<String> {
204 let start = line.find("href=\"")? + 6;
205 let end = line[start..].find('"')?;
206 let mut url = line[start..start + end].to_string();
207 if url.starts_with("//") {
209 url = format!("https:{}", url);
210 }
211 if url.contains("duckduckgo.com/l/?uddg=") {
212 if let Some(real) = url.split("uddg=").nth(1) {
213 if let Ok(decoded) = urlencoding(&real) {
214 url = decoded;
215 }
216 }
217 }
218 Some(url)
219}
220
221pub(crate) fn validate_public_url(url: &str) -> Result<(), &'static str> {
225 resolve_public_url(url).map(|_| ())
226}
227
228pub(crate) fn resolve_public_url(url: &str) -> Result<Vec<std::net::SocketAddr>, &'static str> {
245 let parsed = url::Url::parse(url).map_err(|_| "invalid URL")?;
246 match parsed.scheme() {
247 "http" | "https" => {}
248 _ => return Err("only http(s) is allowed"),
249 }
250 let host = parsed.host_str().ok_or("missing host")?;
251
252 let lc = host.to_ascii_lowercase();
254 if matches!(
255 lc.as_str(),
256 "localhost" | "ip6-localhost" | "ip6-loopback" | "metadata.google.internal" | "metadata"
257 ) || lc.ends_with(".localhost")
258 || lc.ends_with(".local")
259 || lc.ends_with(".internal")
260 {
261 return Err("host points to local/internal network");
262 }
263
264 let port = parsed.port_or_known_default().unwrap_or(0);
265
266 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
268 if is_blocked_ip(&ip) {
269 return Err("IP belongs to a private/loopback/link-local range");
270 }
271 return Ok(vec![std::net::SocketAddr::new(ip, port)]);
272 }
273
274 let mut validated = Vec::new();
280 if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) {
281 for sa in addrs {
282 if is_blocked_ip(&sa.ip()) {
283 return Err("hostname resolves to a private/loopback IP");
284 }
285 validated.push(sa);
286 }
287 }
288 Ok(validated)
289}
290
291fn is_blocked_ip(ip: &std::net::IpAddr) -> bool {
292 match ip {
293 std::net::IpAddr::V4(v4) => {
294 v4.is_loopback()
295 || v4.is_private()
296 || v4.is_link_local()
297 || v4.is_broadcast()
298 || v4.is_multicast()
299 || v4.is_unspecified()
300 || v4.octets() == [169, 254, 169, 254] || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 0x40)
303 }
304 std::net::IpAddr::V6(v6) => {
305 v6.is_loopback()
306 || v6.is_unspecified()
307 || v6.is_multicast()
308 || (v6.segments()[0] & 0xfe00) == 0xfc00
310 || (v6.segments()[0] & 0xffc0) == 0xfe80
311 || v6.to_ipv4_mapped().map(|m| is_blocked_ip(&std::net::IpAddr::V4(m))).unwrap_or(false)
313 }
314 }
315}
316
317fn strip_html(s: &str) -> String {
318 let mut result = String::new();
319 let mut in_tag = false;
320 for c in s.chars() {
321 if c == '<' {
322 in_tag = true;
323 } else if c == '>' {
324 in_tag = false;
325 } else if !in_tag {
326 result.push(c);
327 }
328 }
329 result.trim().to_string()
330}
331
332fn urlencoding(s: &str) -> Result<String, ()> {
333 let mut result = String::new();
334 let chars: Vec<char> = s.chars().collect();
335 let mut i = 0;
336 while i < chars.len() {
337 if chars[i] == '%' && i + 2 < chars.len() {
338 let hex = &s[i + 1..i + 3];
339 if let Ok(byte) = u8::from_str_radix(hex, 16) {
340 result.push(byte as char);
341 i += 3;
342 continue;
343 }
344 }
345 if chars[i] == '+' {
346 result.push(' ');
347 } else {
348 result.push(chars[i]);
349 }
350 i += 1;
351 }
352 Ok(result)
353}
354
355pub struct WebFetch;
358
359#[async_trait]
360impl Tool for WebFetch {
361 fn name(&self) -> &str {
362 "web_fetch"
363 }
364 fn description(&self) -> &str {
365 "Fetch and read content from a URL"
366 }
367 fn schema(&self) -> serde_json::Value {
368 json!({
369 "type": "object",
370 "properties": {
371 "url": { "type": "string", "description": "URL to fetch" },
372 "format": { "type": "string", "enum": ["text", "markdown", "html"], "description": "Output format (default: text)" }
373 },
374 "required": ["url"]
375 })
376 }
377 fn risk(&self) -> RiskLevel {
378 RiskLevel::Network
379 }
380 async fn call(&self, args: serde_json::Value, _ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
381 let url = args["url"].as_str().unwrap_or("");
382 let format = args["format"].as_str().unwrap_or("text");
383
384 let validated_addrs = match resolve_public_url(url) {
385 Ok(addrs) => addrs,
386 Err(why) => return Ok(ToolResult::error(format!("Refused URL ({}): {}", why, url))),
387 };
388
389 let mut builder = reqwest::Client::builder()
390 .user_agent("sparrow/0.1")
391 .timeout(std::time::Duration::from_secs(30))
392 .redirect(reqwest::redirect::Policy::custom(|attempt| {
394 if validate_public_url(attempt.url().as_str()).is_err() {
395 attempt.stop()
396 } else if attempt.previous().len() >= 5 {
397 attempt.stop()
398 } else {
399 attempt.follow()
400 }
401 }));
402
403 if let Some(host) = url::Url::parse(url)
409 .ok()
410 .and_then(|u| u.host_str().map(str::to_owned))
411 {
412 if !validated_addrs.is_empty() {
413 builder = builder.resolve_to_addrs(&host, &validated_addrs);
414 }
415 }
416
417 let client = builder.build()?;
418
419 let resp = client.get(url).send().await?;
420 let status = resp.status();
421 let content_type = resp
422 .headers()
423 .get("content-type")
424 .and_then(|v| v.to_str().ok())
425 .unwrap_or("unknown")
426 .to_string();
427
428 let bytes = resp.bytes().await?;
429
430 let text = match format {
431 "html" => String::from_utf8_lossy(&bytes).to_string(),
432 _ => {
433 let raw = String::from_utf8_lossy(&bytes).to_string();
435 let stripped = strip_html(&raw);
436 if stripped.len() > 50_000 {
438 format!(
439 "{}...\n\n[truncated: {} bytes total]",
440 &stripped[..50_000],
441 stripped.len()
442 )
443 } else {
444 stripped
445 }
446 }
447 };
448
449 Ok(ToolResult::ok(vec![Block::Text(format!(
450 "URL: {}\nStatus: {}\nType: {}\n\n{}",
451 url, status, content_type, text
452 ))]))
453 }
454}
455
456#[cfg(test)]
457mod ssrf_tests {
458 use super::{resolve_public_url, validate_public_url};
459
460 #[test]
461 fn rejects_non_http_schemes() {
462 assert!(validate_public_url("file:///etc/passwd").is_err());
463 assert!(validate_public_url("ftp://example.com/x").is_err());
464 assert!(validate_public_url("gopher://example.com").is_err());
465 }
466
467 #[test]
468 fn rejects_loopback_and_metadata_literals() {
469 for url in [
470 "http://127.0.0.1/",
471 "http://127.0.0.1:8080/admin",
472 "http://[::1]/",
473 "http://169.254.169.254/latest/meta-data/",
474 "http://0.0.0.0/",
475 "http://10.0.0.5/",
476 "http://192.168.1.1/",
477 "http://172.16.4.2/",
478 "http://100.64.1.1/",
480 ] {
481 assert!(validate_public_url(url).is_err(), "should reject {url}");
482 }
483 }
484
485 #[test]
486 fn rejects_local_hostnames() {
487 for url in [
488 "http://localhost/",
489 "http://foo.localhost/",
490 "http://metadata.google.internal/",
491 "http://db.internal/",
492 "http://printer.local/",
493 ] {
494 assert!(validate_public_url(url).is_err(), "should reject {url}");
495 }
496 }
497
498 #[test]
499 fn public_ip_literal_pins_to_itself() {
500 let addrs = resolve_public_url("http://93.184.216.34/").expect("public IP allowed");
503 assert_eq!(addrs.len(), 1);
504 assert_eq!(addrs[0].ip().to_string(), "93.184.216.34");
505 assert_eq!(addrs[0].port(), 80);
506 }
507
508 #[test]
509 fn https_default_port_is_443() {
510 let addrs = resolve_public_url("https://8.8.8.8/").expect("public IP allowed");
511 assert_eq!(addrs[0].port(), 443);
512 }
513}