feat: add HTTP request tool for API interactions

Implements #210 - Add http_request tool that enables the agent to make
HTTP requests to external APIs.

Features:
- Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods
- JSON request/response handling
- Configurable timeout (default: 30s)
- Configurable max response size (default: 1MB)
- Security: domain allowlist, blocks local/private IPs (SSRF protection)
- Headers support with auth token redaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-16 03:44:42 -05:00 committed by GitHub
parent 9bdbc1287c
commit 1140a7887d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 875 additions and 8 deletions

View file

@ -476,6 +476,7 @@ pub async fn run(
mem.clone(),
composio_key,
&config.browser,
&config.http_request,
&config.agents,
config.api_key.as_deref(),
);
@ -966,4 +967,213 @@ I will now call the tool with this payload:
let recalled = mem.recall("45", 5).await.unwrap();
assert!(recalled.iter().any(|entry| entry.content.contains("45")));
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Tool Call Parsing Edge Cases
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_tool_calls_handles_empty_tool_result() {
// Recovery: Empty tool_result tag should be handled gracefully
let response = r#"I'll run that command.
<tool_result name="shell">
</tool_result>
Done."#;
let (text, calls) = parse_tool_calls(response);
assert!(text.contains("Done."));
assert!(calls.is_empty());
}
#[test]
fn parse_arguments_value_handles_null() {
// Recovery: null arguments are returned as-is (Value::Null)
let value = serde_json::json!(null);
let result = parse_arguments_value(Some(&value));
assert!(result.is_null());
}
#[test]
fn parse_tool_calls_handles_empty_tool_calls_array() {
// Recovery: Empty tool_calls array returns original response (no tool parsing)
let response = r#"{"content": "Hello", "tool_calls": []}"#;
let (text, calls) = parse_tool_calls(response);
// When tool_calls is empty, the entire JSON is returned as text
assert!(text.contains("Hello"));
assert!(calls.is_empty());
}
#[test]
fn parse_tool_calls_handles_whitespace_only_name() {
// Recovery: Whitespace-only tool name should return None
let value = serde_json::json!({"function": {"name": " ", "arguments": {}}});
let result = parse_tool_call_value(&value);
assert!(result.is_none());
}
#[test]
fn parse_tool_calls_handles_empty_string_arguments() {
// Recovery: Empty string arguments should be handled
let value = serde_json::json!({"name": "test", "arguments": ""});
let result = parse_tool_call_value(&value);
assert!(result.is_some());
assert_eq!(result.unwrap().name, "test");
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - History Management
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn trim_history_with_no_system_prompt() {
// Recovery: History without system prompt should trim correctly
let mut history = vec![];
for i in 0..MAX_HISTORY_MESSAGES + 20 {
history.push(ChatMessage::user(format!("msg {i}")));
}
trim_history(&mut history);
assert_eq!(history.len(), MAX_HISTORY_MESSAGES);
}
#[test]
fn trim_history_preserves_role_ordering() {
// Recovery: After trimming, role ordering should remain consistent
let mut history = vec![ChatMessage::system("system")];
for i in 0..MAX_HISTORY_MESSAGES + 10 {
history.push(ChatMessage::user(format!("user {i}")));
history.push(ChatMessage::assistant(format!("assistant {i}")));
}
trim_history(&mut history);
assert_eq!(history[0].role, "system");
assert_eq!(history[history.len() - 1].role, "assistant");
}
#[test]
fn trim_history_with_only_system_prompt() {
// Recovery: Only system prompt should not be trimmed
let mut history = vec![ChatMessage::system("system prompt")];
trim_history(&mut history);
assert_eq!(history.len(), 1);
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Arguments Parsing
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_arguments_value_handles_invalid_json_string() {
// Recovery: Invalid JSON string should return empty object
let value = serde_json::Value::String("not valid json".to_string());
let result = parse_arguments_value(Some(&value));
assert!(result.is_object());
assert!(result.as_object().unwrap().is_empty());
}
#[test]
fn parse_arguments_value_handles_none() {
// Recovery: None arguments should return empty object
let result = parse_arguments_value(None);
assert!(result.is_object());
assert!(result.as_object().unwrap().is_empty());
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - JSON Extraction
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn extract_json_values_handles_empty_string() {
// Recovery: Empty input should return empty vec
let result = extract_json_values("");
assert!(result.is_empty());
}
#[test]
fn extract_json_values_handles_whitespace_only() {
// Recovery: Whitespace only should return empty vec
let result = extract_json_values(" \n\t ");
assert!(result.is_empty());
}
#[test]
fn extract_json_values_handles_multiple_objects() {
// Recovery: Multiple JSON objects should all be extracted
let input = r#"{"a": 1}{"b": 2}{"c": 3}"#;
let result = extract_json_values(input);
assert_eq!(result.len(), 3);
}
#[test]
fn extract_json_values_handles_arrays() {
// Recovery: JSON arrays should be extracted
let input = r#"[1, 2, 3]{"key": "value"}"#;
let result = extract_json_values(input);
assert_eq!(result.len(), 2);
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Constants Validation
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn max_tool_iterations_is_reasonable() {
// Recovery: MAX_TOOL_ITERATIONS should be set to prevent runaway loops
assert!(MAX_TOOL_ITERATIONS > 0);
assert!(MAX_TOOL_ITERATIONS <= 100);
}
#[test]
fn max_history_messages_is_reasonable() {
// Recovery: MAX_HISTORY_MESSAGES should be set to prevent memory bloat
assert!(MAX_HISTORY_MESSAGES > 0);
assert!(MAX_HISTORY_MESSAGES <= 1000);
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Tool Call Value Parsing
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_tool_call_value_handles_missing_name_field() {
// Recovery: Missing name field should return None
let value = serde_json::json!({"function": {"arguments": {}}});
let result = parse_tool_call_value(&value);
assert!(result.is_none());
}
#[test]
fn parse_tool_call_value_handles_top_level_name() {
// Recovery: Tool call with name at top level (non-OpenAI format)
let value = serde_json::json!({"name": "test_tool", "arguments": {}});
let result = parse_tool_call_value(&value);
assert!(result.is_some());
assert_eq!(result.unwrap().name, "test_tool");
}
#[test]
fn parse_tool_calls_from_json_value_handles_empty_array() {
// Recovery: Empty tool_calls array should return empty vec
let value = serde_json::json!({"tool_calls": []});
let result = parse_tool_calls_from_json_value(&value);
assert!(result.is_empty());
}
#[test]
fn parse_tool_calls_from_json_value_handles_missing_tool_calls() {
// Recovery: Missing tool_calls field should fall through
let value = serde_json::json!({"name": "test", "arguments": {}});
let result = parse_tool_calls_from_json_value(&value);
assert_eq!(result.len(), 1);
}
#[test]
fn parse_tool_calls_from_json_value_handles_top_level_array() {
// Recovery: Top-level array of tool calls
let value = serde_json::json!([
{"name": "tool_a", "arguments": {}},
{"name": "tool_b", "arguments": {}}
]);
let result = parse_tool_calls_from_json_value(&value);
assert_eq!(result.len(), 2);
}
}

View file

@ -2,8 +2,8 @@ pub mod schema;
pub use schema::{
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DelegateAgentConfig,
DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, IMessageConfig,
IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, ObservabilityConfig,
ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig,
WebhookConfig,
DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, HttpRequestConfig,
IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig,
ObservabilityConfig, ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig,
TelegramConfig, TunnelConfig, WebhookConfig,
};

View file

@ -62,6 +62,9 @@ pub struct Config {
#[serde(default)]
pub browser: BrowserConfig,
#[serde(default)]
pub http_request: HttpRequestConfig,
#[serde(default)]
pub identity: IdentityConfig,
@ -272,6 +275,32 @@ pub struct BrowserConfig {
pub session_name: Option<String>,
}
// ── HTTP request tool ───────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HttpRequestConfig {
/// Enable `http_request` tool for API interactions
#[serde(default)]
pub enabled: bool,
/// Allowed domains for HTTP requests (exact or subdomain match)
#[serde(default)]
pub allowed_domains: Vec<String>,
/// Maximum response size in bytes (default: 1MB)
#[serde(default = "default_http_max_response_size")]
pub max_response_size: usize,
/// Request timeout in seconds (default: 30)
#[serde(default = "default_http_timeout_secs")]
pub timeout_secs: u64,
}
fn default_http_max_response_size() -> usize {
1_000_000 // 1MB
}
fn default_http_timeout_secs() -> u64 {
30
}
// ── Memory ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -906,6 +935,7 @@ impl Default for Config {
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
}
@ -1257,6 +1287,7 @@ mod tests {
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
};
@ -1329,6 +1360,7 @@ default_temperature = 0.7
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
};

View file

@ -105,6 +105,7 @@ pub fn run_wizard() -> Result<Config> {
composio: composio_config,
secrets: secrets_config,
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
identity: crate::config::IdentityConfig::default(),
agents: std::collections::HashMap::new(),
};
@ -297,6 +298,7 @@ pub fn run_quick_setup(
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
identity: crate::config::IdentityConfig::default(),
agents: std::collections::HashMap::new(),
};

605
src/tools/http_request.rs Normal file
View file

@ -0,0 +1,605 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
/// HTTP request tool for API interactions.
/// Supports GET, POST, PUT, DELETE methods with configurable security.
pub struct HttpRequestTool {
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
}
impl HttpRequestTool {
pub fn new(
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
) -> Self {
Self {
security,
allowed_domains: normalize_allowed_domains(allowed_domains),
max_response_size,
timeout_secs,
}
}
fn validate_url(&self, raw_url: &str) -> anyhow::Result<String> {
let url = raw_url.trim();
if url.is_empty() {
anyhow::bail!("URL cannot be empty");
}
if url.chars().any(char::is_whitespace) {
anyhow::bail!("URL cannot contain whitespace");
}
if !url.starts_with("http://") && !url.starts_with("https://") {
anyhow::bail!("Only http:// and https:// URLs are allowed");
}
if self.allowed_domains.is_empty() {
anyhow::bail!(
"HTTP request tool is enabled but no allowed_domains are configured. Add [http_request].allowed_domains in config.toml"
);
}
let host = extract_host(url)?;
if is_private_or_local_host(&host) {
anyhow::bail!("Blocked local/private host: {host}");
}
if !host_matches_allowlist(&host, &self.allowed_domains) {
anyhow::bail!("Host '{host}' is not in http_request.allowed_domains");
}
Ok(url.to_string())
}
fn validate_method(&self, method: &str) -> anyhow::Result<reqwest::Method> {
match method.to_uppercase().as_str() {
"GET" => Ok(reqwest::Method::GET),
"POST" => Ok(reqwest::Method::POST),
"PUT" => Ok(reqwest::Method::PUT),
"DELETE" => Ok(reqwest::Method::DELETE),
"PATCH" => Ok(reqwest::Method::PATCH),
"HEAD" => Ok(reqwest::Method::HEAD),
"OPTIONS" => Ok(reqwest::Method::OPTIONS),
_ => anyhow::bail!("Unsupported HTTP method: {method}. Supported: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
}
}
fn sanitize_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> {
let mut result = Vec::new();
if let Some(obj) = headers.as_object() {
for (key, value) in obj {
if let Some(str_val) = value.as_str() {
// Redact sensitive headers from logs (we don't log headers, but this is defense-in-depth)
let is_sensitive = key.to_lowercase().contains("authorization")
|| key.to_lowercase().contains("api-key")
|| key.to_lowercase().contains("apikey")
|| key.to_lowercase().contains("token")
|| key.to_lowercase().contains("secret");
if is_sensitive {
result.push((key.clone(), "***REDACTED***".into()));
} else {
result.push((key.clone(), str_val.to_string()));
}
}
}
}
result
}
async fn execute_request(
&self,
url: &str,
method: reqwest::Method,
headers: Vec<(String, String)>,
body: Option<&str>,
) -> anyhow::Result<reqwest::Response> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(self.timeout_secs))
.build()?;
let mut request = client.request(method, url);
for (key, value) in headers {
request = request.header(&key, &value);
}
if let Some(body_str) = body {
request = request.body(body_str.to_string());
}
Ok(request.send().await?)
}
fn truncate_response(&self, text: &str) -> String {
if text.len() > self.max_response_size {
let mut truncated = text.chars().take(self.max_response_size).collect::<String>();
truncated.push_str("\n\n... [Response truncated due to size limit] ...");
truncated
} else {
text.to_string()
}
}
}
#[async_trait]
impl Tool for HttpRequestTool {
fn name(&self) -> &str {
"http_request"
}
fn description(&self) -> &str {
"Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \
Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "HTTP or HTTPS URL to request"
},
"method": {
"type": "string",
"description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)",
"default": "GET"
},
"headers": {
"type": "object",
"description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})",
"default": {}
},
"body": {
"type": "string",
"description": "Optional request body (for POST, PUT, PATCH requests)"
}
},
"required": ["url"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let url = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?;
let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
let headers_val = args.get("headers").cloned().unwrap_or(json!({}));
let body = args.get("body").and_then(|v| v.as_str());
if !self.security.can_act() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: autonomy is read-only".into()),
});
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: rate limit exceeded".into()),
});
}
let url = match self.validate_url(url) {
Ok(v) => v,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(e.to_string()),
})
}
};
let method = match self.validate_method(method_str) {
Ok(m) => m,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(e.to_string()),
})
}
};
let sanitized_headers = self.sanitize_headers(&headers_val);
match self.execute_request(&url, method, sanitized_headers, body).await {
Ok(response) => {
let status = response.status();
let status_code = status.as_u16();
// Get response headers (redact sensitive ones)
let response_headers = response.headers().iter();
let headers_text = response_headers
.map(|(k, _)| {
let is_sensitive = k.as_str().to_lowercase().contains("set-cookie");
if is_sensitive {
format!("{}: ***REDACTED***", k.as_str())
} else {
format!("{}: {:?}", k.as_str(), k.as_str())
}
})
.collect::<Vec<_>>()
.join(", ");
// Get response body with size limit
let response_text = match response.text().await {
Ok(text) => self.truncate_response(&text),
Err(e) => format!("[Failed to read response body: {e}]"),
};
let output = format!(
"Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}",
status_code,
status.canonical_reason().unwrap_or("Unknown"),
headers_text,
response_text
);
Ok(ToolResult {
success: status.is_success(),
output,
error: if status.is_client_error() || status.is_server_error() {
Some(format!("HTTP {}", status_code))
} else {
None
},
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("HTTP request failed: {e}")),
}),
}
}
}
// Helper functions similar to browser_open.rs
fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
let mut normalized = domains
.into_iter()
.filter_map(|d| normalize_domain(&d))
.collect::<Vec<_>>();
normalized.sort_unstable();
normalized.dedup();
normalized
}
fn normalize_domain(raw: &str) -> Option<String> {
let mut d = raw.trim().to_lowercase();
if d.is_empty() {
return None;
}
if let Some(stripped) = d.strip_prefix("https://") {
d = stripped.to_string();
} else if let Some(stripped) = d.strip_prefix("http://") {
d = stripped.to_string();
}
if let Some((host, _)) = d.split_once('/') {
d = host.to_string();
}
d = d.trim_start_matches('.').trim_end_matches('.').to_string();
if let Some((host, _)) = d.split_once(':') {
d = host.to_string();
}
if d.is_empty() || d.chars().any(char::is_whitespace) {
return None;
}
Some(d)
}
fn extract_host(url: &str) -> anyhow::Result<String> {
let rest = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?;
let authority = rest
.split(['/', '?', '#'])
.next()
.ok_or_else(|| anyhow::anyhow!("Invalid URL"))?;
if authority.is_empty() {
anyhow::bail!("URL must include a host");
}
if authority.contains('@') {
anyhow::bail!("URL userinfo is not allowed");
}
if authority.starts_with('[') {
anyhow::bail!("IPv6 hosts are not supported in http_request");
}
let host = authority
.split(':')
.next()
.unwrap_or_default()
.trim()
.trim_end_matches('.')
.to_lowercase();
if host.is_empty() {
anyhow::bail!("URL must include a valid host");
}
Ok(host)
}
fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool {
allowed_domains.iter().any(|domain| {
host == domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.'))
})
}
fn is_private_or_local_host(host: &str) -> bool {
let has_local_tld = host
.rsplit('.')
.next()
.is_some_and(|label| label == "local");
if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" {
return true;
}
if let Some([a, b, _, _]) = parse_ipv4(host) {
return a == 0
|| a == 10
|| a == 127
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 100 && (64..=127).contains(&b));
}
false
}
fn parse_ipv4(host: &str) -> Option<[u8; 4]> {
let parts: Vec<&str> = host.split('.').collect();
if parts.len() != 4 {
return None;
}
let mut octets = [0_u8; 4];
for (i, part) in parts.iter().enumerate() {
octets[i] = part.parse::<u8>().ok()?;
}
Some(octets)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_tool(allowed_domains: Vec<&str>) -> HttpRequestTool {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
..SecurityPolicy::default()
});
HttpRequestTool::new(security, allowed_domains.into_iter().map(String::from).collect(), 1_000_000, 30)
}
#[test]
fn normalize_domain_strips_scheme_path_and_case() {
let got = normalize_domain(" HTTPS://Docs.Example.com/path ").unwrap();
assert_eq!(got, "docs.example.com");
}
#[test]
fn normalize_allowed_domains_deduplicates() {
let got = normalize_allowed_domains(vec![
"example.com".into(),
"EXAMPLE.COM".into(),
"https://example.com/".into(),
]);
assert_eq!(got, vec!["example.com".to_string()]);
}
#[test]
fn validate_accepts_exact_domain() {
let tool = test_tool(vec!["example.com"]);
let got = tool.validate_url("https://example.com/docs").unwrap();
assert_eq!(got, "https://example.com/docs");
}
#[test]
fn validate_accepts_http() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_url("http://example.com").is_ok());
}
#[test]
fn validate_accepts_subdomain() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_url("https://api.example.com/v1").is_ok());
}
#[test]
fn validate_rejects_allowlist_miss() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://google.com")
.unwrap_err()
.to_string();
assert!(err.contains("allowed_domains"));
}
#[test]
fn validate_rejects_localhost() {
let tool = test_tool(vec!["localhost"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
}
#[test]
fn validate_rejects_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]);
let err = tool
.validate_url("https://192.168.1.5")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
}
#[test]
fn validate_rejects_whitespace() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://example.com/hello world")
.unwrap_err()
.to_string();
assert!(err.contains("whitespace"));
}
#[test]
fn validate_rejects_userinfo() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://user@example.com")
.unwrap_err()
.to_string();
assert!(err.contains("userinfo"));
}
#[test]
fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default());
let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30);
let err = tool
.validate_url("https://example.com")
.unwrap_err()
.to_string();
assert!(err.contains("allowed_domains"));
}
#[test]
fn validate_accepts_valid_methods() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_method("GET").is_ok());
assert!(tool.validate_method("POST").is_ok());
assert!(tool.validate_method("PUT").is_ok());
assert!(tool.validate_method("DELETE").is_ok());
assert!(tool.validate_method("PATCH").is_ok());
assert!(tool.validate_method("HEAD").is_ok());
assert!(tool.validate_method("OPTIONS").is_ok());
}
#[test]
fn validate_rejects_invalid_method() {
let tool = test_tool(vec!["example.com"]);
let err = tool.validate_method("INVALID").unwrap_err().to_string();
assert!(err.contains("Unsupported HTTP method"));
}
#[test]
fn parse_ipv4_valid() {
assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4]));
}
#[test]
fn parse_ipv4_invalid() {
assert_eq!(parse_ipv4("1.2.3"), None);
assert_eq!(parse_ipv4("1.2.3.999"), None);
assert_eq!(parse_ipv4("not-an-ip"), None);
}
#[tokio::test]
async fn execute_blocks_readonly_mode() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("read-only"));
}
#[tokio::test]
async fn execute_blocks_when_rate_limited() {
let security = Arc::new(SecurityPolicy {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30);
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("rate limit"));
}
#[test]
fn truncate_response_within_limit() {
let tool = test_tool(vec!["example.com"]);
let text = "hello world";
assert_eq!(tool.truncate_response(text), "hello world");
}
#[test]
fn truncate_response_over_limit() {
let tool = HttpRequestTool::new(
Arc::new(SecurityPolicy::default()),
vec!["example.com".into()],
10,
30,
);
let text = "hello world this is long";
let truncated = tool.truncate_response(text);
assert!(truncated.len() <= 10 + 60); // limit + message
assert!(truncated.contains("[Response truncated"));
}
#[test]
fn sanitize_headers_redacts_sensitive() {
let tool = test_tool(vec!["example.com"]);
let headers = json!({
"Authorization": "Bearer secret",
"Content-Type": "application/json",
"X-API-Key": "my-key"
});
let sanitized = tool.sanitize_headers(&headers);
assert_eq!(sanitized.len(), 3);
assert!(sanitized.iter().any(|(k, v)| k == "Authorization" && v == "***REDACTED***"));
assert!(sanitized.iter().any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***"));
assert!(sanitized.iter().any(|(k, v)| k == "Content-Type" && v == "application/json"));
}
}

View file

@ -4,6 +4,7 @@ pub mod composio;
pub mod delegate;
pub mod file_read;
pub mod file_write;
pub mod http_request;
pub mod image_info;
pub mod memory_forget;
pub mod memory_recall;
@ -18,6 +19,7 @@ pub use composio::ComposioTool;
pub use delegate::DelegateTool;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use http_request::HttpRequestTool;
pub use image_info::ImageInfoTool;
pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool;
@ -58,6 +60,7 @@ pub fn all_tools(
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
@ -67,6 +70,7 @@ pub fn all_tools(
memory,
composio_key,
browser_config,
http_config,
agents,
fallback_api_key,
)
@ -79,6 +83,7 @@ pub fn all_tools_with_runtime(
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
@ -105,6 +110,15 @@ pub fn all_tools_with_runtime(
)));
}
if http_config.enabled {
tools.push(Box::new(HttpRequestTool::new(
security.clone(),
http_config.allowed_domains.clone(),
http_config.max_response_size,
http_config.timeout_secs,
)));
}
// Vision tools are always available
tools.push(Box::new(ScreenshotTool::new(security.clone())));
tools.push(Box::new(ImageInfoTool::new(security.clone())));
@ -155,8 +169,9 @@ mod tests {
allowed_domains: vec!["example.com".into()],
session_name: None,
};
let http = crate::config::HttpRequestConfig::default();
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open"));
}
@ -177,8 +192,9 @@ mod tests {
allowed_domains: vec!["example.com".into()],
session_name: None,
};
let http = crate::config::HttpRequestConfig::default();
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"browser_open"));
}
@ -289,6 +305,7 @@ mod tests {
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut agents = HashMap::new();
agents.insert(
@ -303,7 +320,7 @@ mod tests {
},
);
let tools = all_tools(&security, mem, None, &browser, &agents, Some("sk-test"));
let tools = all_tools(&security, mem, None, &browser, &http, &agents, Some("sk-test"));
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"delegate"));
}
@ -320,8 +337,9 @@ mod tests {
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let tools = all_tools(&security, mem, None, &browser, &HashMap::new(), None);
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
}