Skip to main content

aegis_tools/
http.rs

1//! # HttpRequestTool
2//!
3//! A general-purpose authenticated HTTP/REST client so the agent can call any
4//! API (not just search/scrape). SSRF-gated, size-capped, and credential-
5//! sanitized on output. Reuses the existing `reqwest` dependency — no new deps.
6
7use crate::registry::{Tool, ToolContext};
8use aegis_security::{is_safe_url, sanitize_credentials};
9use anyhow::Result;
10use async_trait::async_trait;
11use serde_json::{json, Value};
12
13/// General authenticated HTTP client tool.
14pub struct HttpRequestTool;
15
16impl HttpRequestTool {
17    /// Create a new `HttpRequestTool`.
18    pub fn new() -> Self {
19        HttpRequestTool
20    }
21}
22
23impl Default for HttpRequestTool {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29#[async_trait]
30impl Tool for HttpRequestTool {
31    fn name(&self) -> &str {
32        "http_request"
33    }
34
35    fn description(&self) -> &str {
36        "Make an HTTP request to any API/URL with a chosen method, headers and body. Returns status, headers and (size-capped) body. SSRF-protected; credentials in the output are redacted."
37    }
38
39    fn parameters(&self) -> Value {
40        json!({
41            "type": "object",
42            "properties": {
43                "url": { "type": "string", "description": "Full request URL (http/https)" },
44                "method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], "description": "HTTP method (default GET)" },
45                "headers": { "type": "object", "description": "Request headers as a JSON object of string→string" },
46                "body": { "type": "string", "description": "Request body (for POST/PUT/PATCH)" },
47                "timeout_secs": { "type": "integer", "description": "Request timeout in seconds (default 30)" },
48                "max_bytes": { "type": "integer", "description": "Cap the response body at this many bytes (default 1000000)" }
49            },
50            "required": ["url"]
51        })
52    }
53
54    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
55        let url = args["url"].as_str().unwrap_or("").trim();
56        if url.is_empty() {
57            return Ok("Error: url is required".to_string());
58        }
59
60        // Identity gate: reaching the network can return prompt-injection
61        // payloads, so untrusted trust tiers are denied (mirrors web_extract).
62        if ctx.sandbox_enabled {
63            let identity = ctx.effective_identity();
64            let policy = aegis_security::derive_sandbox_policy(&identity, "http_request", &ctx.cwd);
65            if policy.deny_all {
66                return Ok(format!(
67                    "http_request denied by sandbox policy: identity '{}' (trust '{}') may not make network requests.",
68                    identity.display(),
69                    identity.trust_level(),
70                ));
71            }
72        }
73
74        is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
75
76        let method_str = args["method"].as_str().unwrap_or("GET").to_uppercase();
77        let method = reqwest::Method::from_bytes(method_str.as_bytes())
78            .map_err(|_| anyhow::anyhow!("invalid HTTP method: {method_str}"))?;
79        let timeout = args["timeout_secs"].as_u64().unwrap_or(30);
80        let max_bytes = args["max_bytes"].as_u64().map(|v| v as usize).unwrap_or(1_000_000);
81
82        let client = reqwest::Client::builder()
83            .timeout(std::time::Duration::from_secs(timeout))
84            .user_agent("aegis-agent/2.0 (+https://github.com/Druuugbug/Aegis)")
85            .build()?;
86
87        let mut req = client.request(method, url);
88        if let Some(map) = args["headers"].as_object() {
89            for (k, v) in map {
90                if let Some(vs) = v.as_str() {
91                    req = req.header(k.as_str(), vs);
92                }
93            }
94        }
95        if let Some(body) = args["body"].as_str() {
96            req = req.body(body.to_string());
97        }
98
99        let mut resp = req.send().await?;
100        let status = resp.status();
101
102        // Collect a compact header summary.
103        let mut header_lines = String::new();
104        for (name, value) in resp.headers().iter() {
105            if let Ok(v) = value.to_str() {
106                header_lines.push_str(&format!("{name}: {v}\n"));
107            }
108        }
109
110        // Stream the body up to the cap.
111        let mut body: Vec<u8> = Vec::new();
112        while let Some(chunk) = resp.chunk().await? {
113            body.extend_from_slice(&chunk);
114            if body.len() >= max_bytes {
115                body.truncate(max_bytes);
116                break;
117            }
118        }
119        let body_str = String::from_utf8_lossy(&body);
120
121        let out = format!(
122            "HTTP {} {}\n\n{}\n{}",
123            status.as_u16(),
124            status.canonical_reason().unwrap_or(""),
125            header_lines.trim_end(),
126            body_str
127        );
128        // Redact anything that looks like a secret before returning.
129        Ok(sanitize_credentials(&out))
130    }
131}