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:
parent
9bdbc1287c
commit
1140a7887d
6 changed files with 875 additions and 8 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
605
src/tools/http_request.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue