Skip to main content

argentor_builtins/
http_fetch.rs

1use argentor_core::{ArgentorError, ArgentorResult, ToolCall, ToolResult};
2use argentor_security::{Capability, PermissionSet};
3use argentor_skills::skill::{Skill, SkillDescriptor};
4use async_trait::async_trait;
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
6use std::time::Duration;
7use tracing::{info, warn};
8
9const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; // 5MB
10
11/// Known cloud metadata hostnames that must always be blocked regardless of
12/// what IP they resolve to. Checked via suffix matching so subdomains are
13/// covered (e.g. `foo.metadata.google.internal`).
14const BLOCKED_HOSTNAMES: &[&str] = &[
15    "metadata.google.internal",
16    "metadata.aws.internal",
17    "metadata.goog",
18];
19
20// ---------------------------------------------------------------------------
21// IP-range helpers
22// ---------------------------------------------------------------------------
23
24/// Returns `true` when `ip` belongs to a private, reserved, loopback,
25/// link-local, or otherwise non-globally-routable address range.
26///
27/// Covers:
28///   IPv4 - 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 0.0.0.0,
29///           255.255.255.255, 100.64/10 (CGNAT), 192.0.0/24, 192.0.2/24,
30///           198.51.100/24, 203.0.113/24, 198.18/15, 240/4 (reserved)
31///   IPv6 - ::1, ::, fe80::/10, fc00::/7 (ULA)
32///   Cloud metadata - 169.254.169.254
33fn is_private_ip(ip: IpAddr) -> bool {
34    match ip {
35        IpAddr::V4(v4) => is_private_ipv4(v4),
36        IpAddr::V6(v6) => is_private_ipv6(v6),
37    }
38}
39
40fn is_private_ipv4(ip: Ipv4Addr) -> bool {
41    let octets = ip.octets();
42
43    // Unspecified
44    if ip.is_unspecified() {
45        return true;
46    }
47    // Loopback 127.0.0.0/8
48    if ip.is_loopback() {
49        return true;
50    }
51    // Private 10.0.0.0/8
52    if octets[0] == 10 {
53        return true;
54    }
55    // Private 172.16.0.0/12
56    if octets[0] == 172 && (16..=31).contains(&octets[1]) {
57        return true;
58    }
59    // Private 192.168.0.0/16
60    if octets[0] == 192 && octets[1] == 168 {
61        return true;
62    }
63    // Link-local 169.254.0.0/16  (includes cloud metadata 169.254.169.254)
64    if octets[0] == 169 && octets[1] == 254 {
65        return true;
66    }
67    // Broadcast
68    if ip == Ipv4Addr::BROADCAST {
69        return true;
70    }
71    // CGNAT / Shared address space 100.64.0.0/10
72    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
73        return true;
74    }
75    // IETF Protocol Assignments 192.0.0.0/24
76    if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
77        return true;
78    }
79    // Documentation 192.0.2.0/24
80    if octets[0] == 192 && octets[1] == 0 && octets[2] == 2 {
81        return true;
82    }
83    // Documentation 198.51.100.0/24
84    if octets[0] == 198 && octets[1] == 51 && octets[2] == 100 {
85        return true;
86    }
87    // Documentation 203.0.113.0/24
88    if octets[0] == 203 && octets[1] == 0 && octets[2] == 113 {
89        return true;
90    }
91    // Benchmarking 198.18.0.0/15
92    if octets[0] == 198 && (18..=19).contains(&octets[1]) {
93        return true;
94    }
95    // Reserved / Future use 240.0.0.0/4
96    if octets[0] >= 240 {
97        return true;
98    }
99
100    false
101}
102
103fn is_private_ipv6(ip: Ipv6Addr) -> bool {
104    // Unspecified ::
105    if ip.is_unspecified() {
106        return true;
107    }
108    // Loopback ::1
109    if ip.is_loopback() {
110        return true;
111    }
112    let segments = ip.segments();
113    // Link-local fe80::/10
114    if segments[0] & 0xffc0 == 0xfe80 {
115        return true;
116    }
117    // Unique Local Address fc00::/7
118    if segments[0] & 0xfe00 == 0xfc00 {
119        return true;
120    }
121    // IPv4-mapped ::ffff:0:0/96 — check the embedded IPv4
122    if let Some(v4) = ip.to_ipv4_mapped() {
123        return is_private_ipv4(v4);
124    }
125
126    false
127}
128
129// ---------------------------------------------------------------------------
130// Hostname helpers
131// ---------------------------------------------------------------------------
132
133/// Returns `true` if the hostname itself (before DNS resolution) should be
134/// blocked. This catches well-known cloud metadata names and `localhost`.
135fn is_blocked_hostname(host: &str) -> bool {
136    let lower = host.to_lowercase();
137
138    if lower == "localhost" {
139        return true;
140    }
141
142    BLOCKED_HOSTNAMES
143        .iter()
144        .any(|blocked| lower == *blocked || lower.ends_with(&format!(".{blocked}")))
145}
146
147/// Resolve `host:port` via DNS and return all resolved IP addresses.
148/// Returns an error string if resolution fails.
149async fn resolve_host(host: &str, port: u16) -> Result<Vec<IpAddr>, String> {
150    let addr = format!("{host}:{port}");
151    let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&addr)
152        .await
153        .map_err(|e| format!("DNS resolution failed for '{host}': {e}"))?
154        .collect();
155
156    if addrs.is_empty() {
157        return Err(format!("DNS resolution returned no addresses for '{host}'"));
158    }
159
160    Ok(addrs.into_iter().map(|sa| sa.ip()).collect())
161}
162
163/// Validate that none of the resolved IPs are private/reserved. Returns an
164/// error message if any IP is disallowed.
165fn check_resolved_ips(host: &str, ips: &[IpAddr]) -> Result<(), String> {
166    for ip in ips {
167        if is_private_ip(*ip) {
168            return Err(format!(
169                "Access denied: '{host}' resolves to private/internal address {ip}"
170            ));
171        }
172    }
173    Ok(())
174}
175
176/// Full SSRF validation for a URL: hostname blocklist + DNS resolution + IP
177/// range check. Returns an error message when the URL should be blocked.
178async fn validate_url_ssrf(parsed_url: &reqwest::Url) -> Result<(), String> {
179    let host = parsed_url
180        .host_str()
181        .ok_or_else(|| "URL has no host".to_string())?;
182
183    // 1. Static hostname blocklist (catches metadata services, localhost)
184    if is_blocked_hostname(host) {
185        return Err(format!("Access denied: '{host}' is a blocked hostname"));
186    }
187
188    // 2. If the host is already an IP literal, parse and check directly
189    if let Ok(ip) = host.parse::<IpAddr>() {
190        if is_private_ip(ip) {
191            return Err(format!(
192                "Access denied: '{host}' is a private/internal address"
193            ));
194        }
195        return Ok(());
196    }
197
198    // Also handle bracketed IPv6 in the host string (e.g. "[::1]")
199    let trimmed = host.trim_start_matches('[').trim_end_matches(']');
200    if let Ok(ip) = trimmed.parse::<IpAddr>() {
201        if is_private_ip(ip) {
202            return Err(format!(
203                "Access denied: '{host}' is a private/internal address"
204            ));
205        }
206        return Ok(());
207    }
208
209    // 3. DNS resolution — resolve before connecting
210    let port = parsed_url.port_or_known_default().unwrap_or(80);
211    let ips = resolve_host(host, port).await?;
212    check_resolved_ips(host, &ips)?;
213
214    Ok(())
215}
216
217// ---------------------------------------------------------------------------
218// HttpFetchSkill
219// ---------------------------------------------------------------------------
220
221/// HTTP fetch skill. Makes GET/POST requests to allowed hosts with
222/// production-grade SSRF protection:
223///
224/// - DNS resolution **before** the request, with IP-range validation
225/// - Proper CIDR-based private/reserved range detection (no string-prefix hacks)
226/// - Custom redirect policy that re-validates every redirect target
227/// - Hostname blocklist for cloud metadata endpoints
228pub struct HttpFetchSkill {
229    descriptor: SkillDescriptor,
230    client: reqwest::Client,
231}
232
233impl HttpFetchSkill {
234    /// Create a new HTTP fetch skill with a secure default client.
235    pub fn new() -> Self {
236        // Build a custom redirect policy that validates each hop.
237        let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
238            // Cap total redirects at 10
239            let redirect_count = attempt.previous().len();
240            if redirect_count >= 10 {
241                return attempt.error(format!("too many redirects ({redirect_count})"));
242            }
243
244            let url = attempt.url().clone();
245
246            // Validate scheme
247            match url.scheme() {
248                "http" | "https" => {}
249                scheme => {
250                    return attempt.error(format!("redirect to unsupported scheme '{scheme}'"));
251                }
252            }
253
254            if let Some(host) = url.host_str() {
255                // Block known-bad hostnames
256                if is_blocked_hostname(host) {
257                    return attempt.error(format!("redirect to blocked hostname '{host}'"));
258                }
259
260                // If the redirect target is an IP literal, check it
261                if let Ok(ip) = host.parse::<IpAddr>() {
262                    if is_private_ip(ip) {
263                        return attempt.error(format!("redirect to private IP {ip}"));
264                    }
265                }
266
267                // For hostname redirects, also try trimmed brackets (IPv6)
268                let trimmed = host.trim_start_matches('[').trim_end_matches(']');
269                if let Ok(ip) = trimmed.parse::<IpAddr>() {
270                    if is_private_ip(ip) {
271                        return attempt.error(format!("redirect to private IP {ip}"));
272                    }
273                }
274            }
275
276            // NOTE: We cannot do async DNS resolution inside the synchronous
277            // redirect policy callback. The IP-literal and hostname checks
278            // above cover the most common redirect-based SSRF vectors. For
279            // hostname redirects that resolve to private IPs, the
280            // `reqwest::Client` `resolve` / connect-level socket checks
281            // would be needed (or a proxy layer). The pre-request DNS
282            // validation already catches the initial target.
283            attempt.follow()
284        });
285
286        // reqwest::Client::builder().build() only fails if TLS backend
287        // initialization fails, which indicates a fundamentally broken
288        // environment. We allow expect here because there is no meaningful
289        // recovery path at construction time.
290        #[allow(clippy::expect_used)]
291        let client = reqwest::Client::builder()
292            .timeout(Duration::from_secs(30))
293            .redirect(redirect_policy)
294            .build()
295            .expect("Failed to create HTTP client -- TLS backend unavailable");
296
297        Self {
298            descriptor: SkillDescriptor {
299                name: "http_fetch".to_string(),
300                description: "Fetch content from a URL via HTTP GET or POST.".to_string(),
301                parameters_schema: serde_json::json!({
302                    "type": "object",
303                    "properties": {
304                        "url": {
305                            "type": "string",
306                            "description": "The URL to fetch"
307                        },
308                        "method": {
309                            "type": "string",
310                            "enum": ["GET", "POST"],
311                            "description": "HTTP method (default: GET)"
312                        },
313                        "headers": {
314                            "type": "object",
315                            "description": "Optional HTTP headers as key-value pairs"
316                        },
317                        "body": {
318                            "type": "string",
319                            "description": "Optional request body (for POST)"
320                        }
321                    },
322                    "required": ["url"]
323                }),
324                required_capabilities: vec![Capability::NetworkAccess {
325                    allowed_hosts: vec![], // Configured at runtime
326                }],
327                requires_approval: false,
328            },
329            client,
330        }
331    }
332}
333
334impl Default for HttpFetchSkill {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340#[async_trait]
341impl Skill for HttpFetchSkill {
342    fn descriptor(&self) -> &SkillDescriptor {
343        &self.descriptor
344    }
345
346    fn validate_arguments(
347        &self,
348        call: &ToolCall,
349        permissions: &PermissionSet,
350    ) -> ArgentorResult<()> {
351        let url_str = call.arguments["url"].as_str().unwrap_or_default();
352
353        if url_str.is_empty() {
354            return Ok(()); // Empty URL will be caught in execute()
355        }
356
357        let parsed_url = match reqwest::Url::parse(url_str) {
358            Ok(u) => u,
359            Err(_) => return Ok(()), // Invalid URL will be caught in execute()
360        };
361
362        if let Some(host) = parsed_url.host_str() {
363            if !permissions.check_network(host) {
364                return Err(ArgentorError::Security(format!(
365                    "network access not permitted for host '{host}'"
366                )));
367            }
368        }
369
370        Ok(())
371    }
372
373    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
374        let url = call.arguments["url"]
375            .as_str()
376            .unwrap_or_default()
377            .to_string();
378
379        if url.is_empty() {
380            return Ok(ToolResult::error(&call.id, "Empty URL"));
381        }
382
383        // Parse the URL
384        let parsed_url = match reqwest::Url::parse(&url) {
385            Ok(u) => u,
386            Err(e) => {
387                return Ok(ToolResult::error(
388                    &call.id,
389                    format!("Invalid URL '{url}': {e}"),
390                ));
391            }
392        };
393
394        // Only allow http/https
395        match parsed_url.scheme() {
396            "http" | "https" => {}
397            scheme => {
398                return Ok(ToolResult::error(
399                    &call.id,
400                    format!("Unsupported scheme '{scheme}'. Only http/https allowed."),
401                ));
402            }
403        }
404
405        // SSRF validation: hostname blocklist + DNS resolution + IP range check
406        if let Err(msg) = validate_url_ssrf(&parsed_url).await {
407            warn!(url = %url, reason = %msg, "SSRF protection blocked request");
408            return Ok(ToolResult::error(&call.id, msg));
409        }
410
411        let method = call.arguments["method"]
412            .as_str()
413            .unwrap_or("GET")
414            .to_uppercase();
415
416        info!(url = %url, method = %method, "HTTP fetch");
417
418        let mut request = match method.as_str() {
419            "GET" => self.client.get(&url),
420            "POST" => self.client.post(&url),
421            _ => {
422                return Ok(ToolResult::error(
423                    &call.id,
424                    format!("Unsupported method '{method}'. Use GET or POST."),
425                ));
426            }
427        };
428
429        // Add custom headers
430        if let Some(headers) = call.arguments["headers"].as_object() {
431            for (key, value) in headers {
432                if let Some(v) = value.as_str() {
433                    request = request.header(key.as_str(), v);
434                }
435            }
436        }
437
438        // Add body for POST
439        if method == "POST" {
440            if let Some(body) = call.arguments["body"].as_str() {
441                request = request.body(body.to_string());
442            }
443        }
444
445        let response = match request.send().await {
446            Ok(r) => r,
447            Err(e) => {
448                return Ok(ToolResult::error(
449                    &call.id,
450                    format!("HTTP request failed: {e}"),
451                ));
452            }
453        };
454
455        let status = response.status().as_u16();
456        let headers: serde_json::Map<String, serde_json::Value> = response
457            .headers()
458            .iter()
459            .filter_map(|(k, v)| {
460                v.to_str()
461                    .ok()
462                    .map(|val| (k.to_string(), serde_json::Value::String(val.to_string())))
463            })
464            .collect();
465
466        let content_type = response
467            .headers()
468            .get("content-type")
469            .and_then(|v| v.to_str().ok())
470            .unwrap_or("")
471            .to_string();
472
473        let body_bytes = match response.bytes().await {
474            Ok(b) => b,
475            Err(e) => {
476                return Ok(ToolResult::error(
477                    &call.id,
478                    format!("Failed to read response body: {e}"),
479                ));
480            }
481        };
482
483        if body_bytes.len() > MAX_RESPONSE_SIZE {
484            return Ok(ToolResult::error(
485                &call.id,
486                format!(
487                    "Response too large: {} bytes (max: {} bytes)",
488                    body_bytes.len(),
489                    MAX_RESPONSE_SIZE
490                ),
491            ));
492        }
493
494        let body = String::from_utf8_lossy(&body_bytes);
495
496        let result = serde_json::json!({
497            "status": status,
498            "headers": headers,
499            "content_type": content_type,
500            "body": body,
501            "size": body_bytes.len(),
502        });
503
504        if (200..400).contains(&status) {
505            Ok(ToolResult::success(&call.id, result.to_string()))
506        } else {
507            Ok(ToolResult::error(&call.id, result.to_string()))
508        }
509    }
510}
511
512// ---------------------------------------------------------------------------
513// Tests
514// ---------------------------------------------------------------------------
515
516#[cfg(test)]
517#[allow(clippy::unwrap_used, clippy::expect_used)]
518mod tests {
519    use super::*;
520
521    // -- is_private_ip unit tests -------------------------------------------
522
523    #[test]
524    fn test_is_private_ip_comprehensive() {
525        // IPv4 loopback
526        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
527        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255))));
528
529        // IPv4 private 10/8
530        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
531        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255))));
532
533        // IPv4 private 172.16/12
534        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
535        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255))));
536        // 172.15 and 172.32 are NOT private
537        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 15, 0, 1))));
538        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1))));
539
540        // IPv4 private 192.168/16
541        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
542        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 255, 255))));
543
544        // IPv4 link-local 169.254/16
545        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
546        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
547
548        // IPv4 unspecified
549        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
550
551        // IPv4 broadcast
552        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::BROADCAST)));
553
554        // IPv4 CGNAT 100.64/10
555        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
556        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 127, 255, 255))));
557        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255))));
558
559        // IPv4 reserved 240/4
560        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
561        assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(255, 255, 255, 254))));
562
563        // Public IPv4 -- NOT private
564        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
565        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
566        assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))));
567
568        // IPv6 loopback
569        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
570
571        // IPv6 unspecified
572        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
573
574        // IPv6 link-local fe80::/10
575        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
576            0xfe80, 0, 0, 0, 0, 0, 0, 1
577        ))));
578        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
579            0xfebf, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
580        ))));
581
582        // IPv6 ULA fc00::/7
583        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
584            0xfc00, 0, 0, 0, 0, 0, 0, 1
585        ))));
586        assert!(is_private_ip(IpAddr::V6(Ipv6Addr::new(
587            0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
588        ))));
589
590        // Public IPv6 -- NOT private
591        assert!(!is_private_ip(IpAddr::V6(Ipv6Addr::new(
592            0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
593        ))));
594    }
595
596    // -- Hostname blocklist tests -------------------------------------------
597
598    #[test]
599    fn test_blocked_hostnames() {
600        assert!(is_blocked_hostname("localhost"));
601        assert!(is_blocked_hostname("LOCALHOST"));
602        assert!(is_blocked_hostname("metadata.google.internal"));
603        assert!(is_blocked_hostname("foo.metadata.google.internal"));
604        assert!(is_blocked_hostname("metadata.aws.internal"));
605
606        assert!(!is_blocked_hostname("google.com"));
607        assert!(!is_blocked_hostname("api.anthropic.com"));
608        assert!(!is_blocked_hostname("example.com"));
609    }
610
611    // -- Original tests (preserved) -----------------------------------------
612
613    #[test]
614    fn test_private_host_detection() {
615        // The old is_private_host is replaced; these now exercise the new
616        // IP-based checks via is_private_ip + is_blocked_hostname.
617        assert!(is_blocked_hostname("localhost"));
618        assert!(is_private_ip("127.0.0.1".parse().unwrap()));
619        assert!(is_private_ip("192.168.1.1".parse().unwrap()));
620        assert!(is_private_ip("10.0.0.1".parse().unwrap()));
621        assert!(is_private_ip("169.254.169.254".parse().unwrap()));
622        assert!(is_blocked_hostname("metadata.google.internal"));
623        assert!(!is_private_ip("93.184.216.34".parse().unwrap())); // example.com
624        assert!(!is_blocked_hostname("api.anthropic.com"));
625    }
626
627    #[tokio::test]
628    async fn test_http_fetch_invalid_url() {
629        let skill = HttpFetchSkill::new();
630        let call = ToolCall {
631            id: "test_1".to_string(),
632            name: "http_fetch".to_string(),
633            arguments: serde_json::json!({"url": "not a url"}),
634        };
635        let result = skill.execute(call).await.unwrap();
636        assert!(result.is_error);
637    }
638
639    #[tokio::test]
640    async fn test_http_fetch_blocks_ssrf() {
641        let skill = HttpFetchSkill::new();
642        let call = ToolCall {
643            id: "test_2".to_string(),
644            name: "http_fetch".to_string(),
645            arguments: serde_json::json!({"url": "http://169.254.169.254/latest/meta-data/"}),
646        };
647        let result = skill.execute(call).await.unwrap();
648        assert!(result.is_error);
649        assert!(result.content.contains("private") || result.content.contains("Access denied"));
650    }
651
652    #[tokio::test]
653    async fn test_http_fetch_blocks_localhost() {
654        let skill = HttpFetchSkill::new();
655        let call = ToolCall {
656            id: "test_3".to_string(),
657            name: "http_fetch".to_string(),
658            arguments: serde_json::json!({"url": "http://localhost:8080/admin"}),
659        };
660        let result = skill.execute(call).await.unwrap();
661        assert!(result.is_error);
662    }
663
664    #[tokio::test]
665    async fn test_http_fetch_blocks_bad_scheme() {
666        let skill = HttpFetchSkill::new();
667        let call = ToolCall {
668            id: "test_4".to_string(),
669            name: "http_fetch".to_string(),
670            arguments: serde_json::json!({"url": "file:///etc/passwd"}),
671        };
672        let result = skill.execute(call).await.unwrap();
673        assert!(result.is_error);
674    }
675
676    // -- New SSRF tests -----------------------------------------------------
677
678    #[tokio::test]
679    async fn test_blocks_ipv6_loopback() {
680        let skill = HttpFetchSkill::new();
681        let call = ToolCall {
682            id: "test_ipv6_lo".to_string(),
683            name: "http_fetch".to_string(),
684            arguments: serde_json::json!({"url": "http://[::1]:8080/"}),
685        };
686        let result = skill.execute(call).await.unwrap();
687        assert!(result.is_error, "IPv6 loopback must be blocked");
688        assert!(
689            result.content.contains("private") || result.content.contains("Access denied"),
690            "Error should mention private/access denied, got: {}",
691            result.content
692        );
693    }
694
695    #[tokio::test]
696    async fn test_blocks_zero_address() {
697        let skill = HttpFetchSkill::new();
698        let call = ToolCall {
699            id: "test_zero".to_string(),
700            name: "http_fetch".to_string(),
701            arguments: serde_json::json!({"url": "http://0.0.0.0:8080/"}),
702        };
703        let result = skill.execute(call).await.unwrap();
704        assert!(result.is_error, "0.0.0.0 must be blocked");
705        assert!(
706            result.content.contains("private") || result.content.contains("Access denied"),
707            "Error should mention private/access denied, got: {}",
708            result.content
709        );
710    }
711
712    #[tokio::test]
713    async fn test_blocks_metadata_variants() {
714        let skill = HttpFetchSkill::new();
715
716        // AWS-style metadata IP
717        let call = ToolCall {
718            id: "test_meta_ip".to_string(),
719            name: "http_fetch".to_string(),
720            arguments: serde_json::json!({"url": "http://169.254.169.254/"}),
721        };
722        let result = skill.execute(call).await.unwrap();
723        assert!(result.is_error, "169.254.169.254 must be blocked");
724
725        // GCP metadata hostname
726        let call = ToolCall {
727            id: "test_meta_gcp".to_string(),
728            name: "http_fetch".to_string(),
729            arguments: serde_json::json!({"url": "http://metadata.google.internal/"}),
730        };
731        let result = skill.execute(call).await.unwrap();
732        assert!(result.is_error, "metadata.google.internal must be blocked");
733    }
734
735    #[tokio::test]
736    async fn test_allows_public_host() {
737        let skill = HttpFetchSkill::new();
738        let call = ToolCall {
739            id: "test_public".to_string(),
740            name: "http_fetch".to_string(),
741            arguments: serde_json::json!({"url": "http://example.com/"}),
742        };
743        let result = skill.execute(call).await.unwrap();
744        // The request itself may fail (network, timeout, etc.) but it should
745        // NOT be blocked by SSRF protection. If it *is* blocked, our
746        // content will contain "Access denied" or "blocked hostname".
747        let blocked =
748            result.content.contains("Access denied") || result.content.contains("blocked hostname");
749        assert!(
750            !blocked,
751            "Public host example.com should not be blocked by SSRF protection, got: {}",
752            result.content
753        );
754    }
755
756    // -- validate_arguments tests -------------------------------------------
757
758    #[test]
759    fn test_validate_arguments_denies_disallowed_host() {
760        let skill = HttpFetchSkill::new();
761        let mut perms = PermissionSet::new();
762        perms.grant(Capability::NetworkAccess {
763            allowed_hosts: vec!["api.anthropic.com".to_string()],
764        });
765
766        let call = ToolCall {
767            id: "test_va_1".to_string(),
768            name: "http_fetch".to_string(),
769            arguments: serde_json::json!({"url": "http://evil.com/payload"}),
770        };
771        let result = skill.validate_arguments(&call, &perms);
772        assert!(result.is_err());
773    }
774
775    #[test]
776    fn test_validate_arguments_allows_permitted_host() {
777        let skill = HttpFetchSkill::new();
778        let mut perms = PermissionSet::new();
779        perms.grant(Capability::NetworkAccess {
780            allowed_hosts: vec!["api.anthropic.com".to_string()],
781        });
782
783        let call = ToolCall {
784            id: "test_va_2".to_string(),
785            name: "http_fetch".to_string(),
786            arguments: serde_json::json!({"url": "https://api.anthropic.com/v1/messages"}),
787        };
788        let result = skill.validate_arguments(&call, &perms);
789        assert!(result.is_ok());
790    }
791
792    #[test]
793    fn test_validate_arguments_wildcard_allows_all() {
794        let skill = HttpFetchSkill::new();
795        let mut perms = PermissionSet::new();
796        perms.grant(Capability::NetworkAccess {
797            allowed_hosts: vec!["*".to_string()],
798        });
799
800        let call = ToolCall {
801            id: "test_va_3".to_string(),
802            name: "http_fetch".to_string(),
803            arguments: serde_json::json!({"url": "https://any-host.example.com/path"}),
804        };
805        let result = skill.validate_arguments(&call, &perms);
806        assert!(result.is_ok());
807    }
808}