use super::net_policy::{self, NetworkPolicy};
use super::Tool;
use crate::safety::PinnedDnsResolver;
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub struct HttpRequest;
type HttpTargetPolicy = NetworkPolicy;
#[async_trait]
impl Tool for HttpRequest {
fn name(&self) -> &str {
"http_request"
}
fn description(&self) -> &str {
"Make HTTP requests to APIs or web endpoints. Supports GET, POST, PUT, DELETE methods. \
Use for fetching documentation, calling APIs, or testing endpoints. \
Localhost/loopback URLs are allowed by default; set SELFWARE_ALLOW_PRIVATE_NETWORK=1 for private LAN hosts."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to request"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
"default": "GET",
"description": "HTTP method"
},
"headers": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Request headers"
},
"body": {
"type": "string",
"description": "Request body (for POST/PUT/PATCH)"
},
"timeout_secs": {
"type": "integer",
"default": 30,
"description": "Request timeout in seconds"
},
"follow_redirects": {
"type": "boolean",
"default": true,
"description": "Whether to follow redirects"
}
},
"required": ["url"]
})
}
async fn execute(&self, args: Value) -> Result<Value> {
#[derive(Deserialize)]
struct Args {
url: String,
#[serde(default = "default_method")]
method: String,
#[serde(default)]
headers: HashMap<String, String>,
body: Option<String>,
#[serde(default = "default_timeout")]
timeout_secs: u64,
#[serde(default = "default_true")]
follow_redirects: bool,
}
fn default_method() -> String {
"GET".to_string()
}
fn default_timeout() -> u64 {
30
}
fn default_true() -> bool {
true
}
let mut args: Args = serde_json::from_value(args)?;
const MAX_TIMEOUT_SECS: u64 = 300;
args.timeout_secs = args.timeout_secs.min(MAX_TIMEOUT_SECS);
let url = reqwest::Url::parse(&args.url).context("Invalid URL")?;
let allow_private =
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1";
let policy = validate_http_request_target(&url, allow_private)?;
let builder = Client::builder()
.timeout(Duration::from_secs(args.timeout_secs))
.dns_resolver(Arc::new(PinnedDnsResolver::new(
policy.allow_private || policy.allow_localhost,
)));
if let Some(host) = url.host_str() {
if net_policy::is_private_network_host(host)
&& policy.allow_private
&& !policy.allow_localhost
{
tracing::warn!(
"Allowing request to private network (SELFWARE_ALLOW_PRIVATE_NETWORK=1): {}",
host
);
}
}
let client = builder
.redirect(if args.follow_redirects {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() > 10 {
return attempt.error("Too many redirects");
}
if let Some(host) = attempt.url().host_str().map(|h| h.to_owned()) {
if !policy.allow_private
&& !net_policy::is_trusted_local_network_host(&host)
&& net_policy::is_private_network_host(&host)
{
return attempt
.error("Blocked redirect to private/internal network address");
}
}
attempt.follow()
})
} else {
reqwest::redirect::Policy::none()
})
.build()
.context("Failed to build HTTP client")?;
let mut request = match args.method.to_uppercase().as_str() {
"GET" => client.get(&args.url),
"POST" => client.post(&args.url),
"PUT" => client.put(&args.url),
"DELETE" => client.delete(&args.url),
"PATCH" => client.patch(&args.url),
"HEAD" => client.head(&args.url),
_ => anyhow::bail!("Unsupported HTTP method: {}", args.method),
};
for (key, value) in &args.headers {
request = request.header(key, value);
}
if let Some(body) = args.body {
request = request.body(body);
}
let start = std::time::Instant::now();
let response = request
.send()
.await
.context("Failed to send HTTP request")?;
let duration_ms = start.elapsed().as_millis() as u64;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("Unknown");
let response_headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let content_type = response_headers
.get("content-type")
.cloned()
.unwrap_or_default();
let body = response
.text()
.await
.context("Failed to read response body")?;
let truncated = body.len() > 50000;
let body = if truncated {
let safe_truncate: String = body.chars().take(50000).collect();
format!(
"{}...[truncated, {} bytes total]",
safe_truncate,
body.len()
)
} else {
body
};
let body_json: Option<Value> = if content_type.contains("application/json") {
serde_json::from_str(&body).ok()
} else {
None
};
Ok(serde_json::json!({
"status": status,
"status_text": status_text,
"headers": response_headers,
"body": body,
"body_json": body_json,
"duration_ms": duration_ms,
"truncated": truncated
}))
}
}
fn validate_http_request_target(
url: &reqwest::Url,
allow_private: bool,
) -> Result<HttpTargetPolicy> {
net_policy::validate_url_target(url, allow_private)
}
#[cfg(test)]
#[path = "../../tests/unit/tools/http/http_test.rs"]
mod tests;