Skip to main content

sparrow_tools/
search_and_web.rs

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
7// ─── Ripgrep search ─────────────────────────────────────────────────────────────
8
9pub 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                // Fallback: basic string search
66                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
131// ─── Web search ─────────────────────────────────────────────────────────────────
132
133pub 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        // Use DuckDuckGo Lite as a free search backend
165        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        // Simple extraction of result snippets
174        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    // Clean up DuckDuckGo redirect URLs
208    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
221/// Reject non-http(s) schemes and any URL whose host resolves to a private,
222/// loopback, link-local, multicast, or unspecified address (SSRF defence).
223/// Also rejects bare IPs in those ranges and the AWS/GCP metadata endpoints.
224pub(crate) fn validate_public_url(url: &str) -> Result<(), &'static str> {
225    resolve_public_url(url).map(|_| ())
226}
227
228/// Like [`validate_public_url`], but on success also returns the validated
229/// socket addresses for the host.
230///
231/// Callers should pin their HTTP client to exactly these addresses (e.g. via
232/// `reqwest::ClientBuilder::resolve_to_addrs`). Validating the hostname and
233/// then letting the client re-resolve at connect time leaves a DNS-rebinding
234/// TOCTOU window: an attacker domain can answer with a public IP during this
235/// check and `127.0.0.1` (or `169.254.169.254`) microseconds later when the
236/// request actually dials. Pinning closes that window — we connect only to the
237/// IPs we just screened.
238///
239/// For bare-IP literals there is nothing to rebind, so the returned vec simply
240/// carries that single address. When a hostname cannot be resolved here we
241/// return `Ok(vec![])` rather than failing: resolution may be transient and the
242/// client will surface a real connection error — but with an empty pin set the
243/// caller MUST fall back to its own (still redirect-guarded) resolution.
244pub(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    // Block obvious well-known metadata / loopback hostnames.
253    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 the host parses as an IP literal, check the ranges directly.
267    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    // Hostname: best-effort DNS check. We can't await here without making the
275    // fn async, so we resolve synchronously via the std API. A single resolution
276    // is cheap and prevents the most common SSRF payloads (`127.0.0.1`-aliasing
277    // domains, hosts file tricks, etc.). Every returned address is screened, and
278    // the screened set is handed back so the caller can pin to it.
279    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] // AWS/GCP/Azure metadata
301                // Carrier-grade NAT 100.64.0.0/10
302                || (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                // fc00::/7 (unique local), fe80::/10 (link-local)
309                || (v6.segments()[0] & 0xfe00) == 0xfc00
310                || (v6.segments()[0] & 0xffc0) == 0xfe80
311                // IPv4-mapped: re-check the embedded v4
312                || 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
355// ─── Web fetch ──────────────────────────────────────────────────────────────────
356
357pub 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            // Belt-and-suspenders: re-validate after redirects to block private-IP redirect attacks.
393            .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        // Pin the connection to the exact IPs we just screened so a rebinding
404        // DNS server can't swap in a loopback/metadata address between the check
405        // above and the actual dial. Only applies when the host is a name we
406        // resolved here; bare-IP literals and unresolvable names fall through to
407        // reqwest's own resolver (still covered by the redirect guard).
408        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                // Simple HTML to text conversion
434                let raw = String::from_utf8_lossy(&bytes).to_string();
435                let stripped = strip_html(&raw);
436                // Truncate very long content
437                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            // Carrier-grade NAT 100.64.0.0/10
479            "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        // A public IP literal needs no DNS and cannot be rebound: the pin set is
501        // exactly that address.
502        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}