merge: Resolve conflicts with main, add user-scoped DB methods

Merge main's security hardening (user-scoped job/conversation access,
cargo-dist config, CI improvements) into turso branch.

Add Database trait methods for user-scoped operations:
- list_sandbox_jobs_for_user
- sandbox_job_summary_for_user
- sandbox_job_belongs_to_user
- conversation_belongs_to_user

Implemented in both postgres and libsql backends.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki
2026-02-13 07:30:27 -08:00
co-authored by Claude Opus 4.6
74 changed files with 5163 additions and 443 deletions
+1 -1
View File
@@ -595,7 +595,7 @@ Create alongside the .wasm file to grant capabilities:
AgentToolError::BuilderFailed(format!("LLM response failed: {}", e))
})?;
match result {
match result.result {
RespondResult::Text(response) => {
reason_ctx.messages.push(ChatMessage::assistant(&response));
+138 -36
View File
@@ -36,7 +36,7 @@ fn is_workspace_path(path: &str) -> bool {
.and_then(|f| f.to_str())
.unwrap_or(path);
WORKSPACE_FILES.iter().any(|ws| *ws == filename)
WORKSPACE_FILES.contains(&filename)
|| path.starts_with("daily/")
|| path.starts_with("context/")
}
@@ -50,65 +50,90 @@ const MAX_WRITE_SIZE: usize = 5 * 1024 * 1024;
/// Maximum directory listing entries.
const MAX_DIR_ENTRIES: usize = 500;
/// Validate that a path is safe (no traversal attacks).
fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result<PathBuf, ToolError> {
let path = PathBuf::from(path_str);
// Reject paths with suspicious components (validation only, no action needed)
/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
///
/// This is critical for security: `std::fs::canonicalize` only works on paths that exist,
/// so for new files we must normalize without touching the filesystem.
fn normalize_lexical(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
// Allow .. but validate final path is within sandbox
}
std::path::Component::Normal(s) => {
let s = s.to_string_lossy();
if s.starts_with('.') && s != "." && s != ".." && !s.starts_with(".git") {
// Hidden files are OK for .git, .gitignore, etc.
// Only pop if there's a normal component to pop (don't escape root/prefix)
if components
.last()
.is_some_and(|c| matches!(c, std::path::Component::Normal(_)))
{
components.pop();
}
}
_ => {}
std::path::Component::CurDir => {}
other => components.push(other),
}
}
components.iter().collect()
}
/// Validate that a path is safe (no traversal attacks).
///
/// For sandboxed paths (base_dir is set), we normalize the joined path lexically
/// and then verify it lives under the canonical base. This prevents escapes through
/// non-existent parent directories where `canonicalize()` would fall back to the
/// raw (un-normalized) path.
fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result<PathBuf, ToolError> {
let path = PathBuf::from(path_str);
// Resolve to absolute path
let resolved = if path.is_absolute() {
path.canonicalize().unwrap_or_else(|_| path.clone())
path.canonicalize()
.unwrap_or_else(|_| normalize_lexical(&path))
} else if let Some(base) = base_dir {
base.join(&path)
let joined = base.join(&path);
joined
.canonicalize()
.unwrap_or_else(|_| base.join(&path))
.unwrap_or_else(|_| normalize_lexical(&joined))
} else {
std::env::current_dir()
let joined = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&path)
.join(&path);
normalize_lexical(&joined)
};
// If base_dir is set, ensure path is within it
// If base_dir is set, ensure the resolved path is within it
if let Some(base) = base_dir {
// Canonicalize the base to handle symlinks (e.g., /var -> /private/var on macOS)
let base_canonical = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
let base_canonical = base
.canonicalize()
.unwrap_or_else(|_| normalize_lexical(base));
// For files that don't exist yet, we need to check the parent directory
// and ensure the resolved path would be within the base
// For existing paths, canonicalize to resolve symlinks.
// For non-existent paths, the lexical normalization above already removed
// all `..` components, so starts_with is reliable.
let check_path = if resolved.exists() {
resolved.canonicalize().unwrap_or_else(|_| resolved.clone())
} else {
// For non-existent files, canonicalize the parent and append the filename
if let Some(parent) = resolved.parent() {
if parent.exists() {
let canonical_parent = parent
// Walk up to the nearest existing ancestor directory, canonicalize it,
// then re-append the remaining tail. This handles the case where a
// symlink sits above the new file.
let mut ancestor = resolved.as_path();
let mut tail_parts: Vec<&std::ffi::OsStr> = Vec::new();
loop {
if ancestor.exists() {
let canonical_ancestor = ancestor
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
if let Some(filename) = resolved.file_name() {
canonical_parent.join(filename)
} else {
resolved.clone()
.unwrap_or_else(|_| ancestor.to_path_buf());
let mut result = canonical_ancestor;
for part in tail_parts.into_iter().rev() {
result = result.join(part);
}
} else {
resolved.clone()
break result;
}
if let Some(name) = ancestor.file_name() {
tail_parts.push(name);
}
match ancestor.parent() {
Some(parent) if parent != ancestor => ancestor = parent,
_ => break resolved.clone(),
}
} else {
resolved.clone()
}
};
@@ -871,4 +896,81 @@ mod tests {
let entries = result.result.get("entries").unwrap().as_array().unwrap();
assert!(entries.len() >= 2);
}
#[test]
fn test_normalize_lexical() {
// Basic .. resolution
assert_eq!(
normalize_lexical(Path::new("/a/b/../c")),
PathBuf::from("/a/c")
);
// Multiple .. components
assert_eq!(
normalize_lexical(Path::new("/a/b/c/../../d")),
PathBuf::from("/a/d")
);
// . components stripped
assert_eq!(
normalize_lexical(Path::new("/a/./b/./c")),
PathBuf::from("/a/b/c")
);
// Cannot escape root
assert_eq!(
normalize_lexical(Path::new("/a/../../..")),
PathBuf::from("/")
);
}
#[test]
fn test_validate_path_rejects_traversal_nonexistent_parent() {
// The critical test: writing to ../../outside/newdir/file with base_dir
// set should be rejected even when the parent directory does not exist
// (i.e. canonicalize() cannot resolve it).
let dir = TempDir::new().unwrap();
let evil_path = format!(
"{}/../../outside/newdir/file.txt",
dir.path().to_str().unwrap()
);
let result = validate_path(&evil_path, Some(dir.path()));
assert!(
result.is_err(),
"Should reject traversal via non-existent parent, got: {:?}",
result
);
}
#[test]
fn test_validate_path_rejects_relative_traversal() {
let dir = TempDir::new().unwrap();
let result = validate_path("../../etc/passwd", Some(dir.path()));
assert!(
result.is_err(),
"Should reject relative traversal, got: {:?}",
result
);
}
#[test]
fn test_validate_path_allows_valid_nested_write() {
let dir = TempDir::new().unwrap();
let result = validate_path("subdir/newfile.txt", Some(dir.path()));
assert!(
result.is_ok(),
"Should allow nested writes within sandbox: {:?}",
result
);
}
#[test]
fn test_validate_path_allows_dot_dot_within_sandbox() {
// a/b/../c resolves to a/c which is still inside the sandbox
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
let result = validate_path("a/b/../c.txt", Some(dir.path()));
assert!(
result.is_ok(),
"Should allow .. that stays within sandbox: {:?}",
result
);
}
}
+80 -4
View File
@@ -1,7 +1,7 @@
//! HTTP request tool.
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::{IpAddr, ToSocketAddrs};
use std::time::Duration;
use async_trait::async_trait;
@@ -11,6 +11,9 @@ use crate::context::JobContext;
use crate::safety::LeakDetector;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
/// Tool for making HTTP requests.
pub struct HttpTool {
client: Client,
@@ -21,6 +24,7 @@ impl HttpTool {
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to create HTTP client");
@@ -49,6 +53,7 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
));
}
// Check literal IP addresses
if let Ok(ip) = host.parse::<IpAddr>() {
if is_disallowed_ip(&ip) {
return Err(ToolError::NotAuthorized(
@@ -57,6 +62,22 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
}
}
// Resolve hostname and check all resolved IPs against the blocklist.
// This prevents DNS rebinding where a hostname resolves to a private IP.
let port = parsed.port_or_known_default().unwrap_or(443);
let socket_addr = format!("{}:{}", host, port);
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ToolError::NotAuthorized(format!(
"hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
}
Ok(parsed)
}
@@ -202,17 +223,36 @@ impl Tool for HttpTool {
})?;
let status = response.status().as_u16();
// Block redirects: the server tried to send us elsewhere (potential SSRF)
if (300..400).contains(&status) {
return Err(ToolError::NotAuthorized(format!(
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
status
)));
}
let headers: HashMap<String, String> = response
.headers()
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
.collect();
// Get response body
let body_text = response.text().await.map_err(|e| {
// Get response body with size cap to prevent OOM
let body_bytes = response.bytes().await.map_err(|e| {
ToolError::ExternalService(format!("failed to read response body: {}", e))
})?;
if body_bytes.len() > MAX_RESPONSE_SIZE {
return Err(ToolError::ExecutionFailed(format!(
"Response body too large ({} bytes, max {})",
body_bytes.len(),
MAX_RESPONSE_SIZE
)));
}
let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
// Try to parse as JSON, fall back to string
let body: serde_json::Value = serde_json::from_str(&body_text)
.unwrap_or_else(|_| serde_json::Value::String(body_text.clone()));
@@ -241,7 +281,7 @@ impl Tool for HttpTool {
#[cfg(test)]
mod tests {
use super::validate_url;
use super::*;
#[test]
fn test_validate_url_rejects_http() {
@@ -260,4 +300,40 @@ mod tests {
let url = validate_url("https://example.com").unwrap();
assert_eq!(url.host_str(), Some("example.com"));
}
#[test]
fn test_validate_url_rejects_private_ip_literal() {
let err = validate_url("https://192.168.1.1/api").unwrap_err();
assert!(err.to_string().contains("private"));
}
#[test]
fn test_validate_url_rejects_loopback_ip() {
let err = validate_url("https://127.0.0.1/api").unwrap_err();
assert!(err.to_string().contains("private"));
}
#[test]
fn test_validate_url_rejects_link_local() {
let err = validate_url("https://169.254.169.254/latest/meta-data/").unwrap_err();
assert!(err.to_string().contains("private"));
}
#[test]
fn test_is_disallowed_ip_covers_ranges() {
use std::net::Ipv4Addr;
// Private ranges
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))));
// Loopback
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::LOCALHOST)));
// Cloud metadata
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
// Public
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
}
}
+30
View File
@@ -20,6 +20,12 @@ use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::workspace::{Workspace, paths};
/// Identity files that the LLM must not overwrite via tool calls.
/// These are loaded into the system prompt and could be used for prompt
/// injection if an attacker tricks the agent into overwriting them.
const PROTECTED_IDENTITY_FILES: &[&str] =
&[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];
/// Tool for searching workspace memory.
///
/// Performs hybrid search (FTS + semantic) across all memory documents.
@@ -188,6 +194,16 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_str())
.unwrap_or("daily_log");
// Reject writes to identity files that are loaded into the system prompt.
// An attacker could use prompt injection to trick the agent into overwriting
// these, poisoning future conversations.
if PROTECTED_IDENTITY_FILES.contains(&target) {
return Err(ToolError::NotAuthorized(format!(
"writing to '{}' is not allowed (identity file protected from tool writes)",
target,
)));
}
let append = params
.get("append")
.and_then(|v| v.as_bool())
@@ -230,6 +246,20 @@ impl Tool for MemoryWriteTool {
paths::HEARTBEAT.to_string()
}
path => {
// Protect identity files from LLM overwrites (prompt injection defense).
// These files are injected into the system prompt, so poisoning them
// would let an attacker rewrite the agent's core instructions.
let normalized = path.trim_start_matches('/');
if PROTECTED_IDENTITY_FILES
.iter()
.any(|p| normalized.eq_ignore_ascii_case(p))
{
return Err(ToolError::NotAuthorized(format!(
"writing to '{}' is not allowed (identity file protected from tool access)",
path
)));
}
if append {
self.workspace
.append(path, content)
+1 -1
View File
@@ -11,7 +11,7 @@ mod marketplace;
mod memory;
mod restaurant;
pub mod routine;
mod shell;
pub(crate) mod shell;
mod taskrabbit;
mod time;
+83 -14
View File
@@ -74,6 +74,58 @@ static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
]
});
/// Patterns that should NEVER be auto-approved, even if the user chose "always approve"
/// for the shell tool. These require explicit per-invocation approval because they are
/// destructive or security-sensitive.
static NEVER_AUTO_APPROVE_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"rm -rf",
"rm -fr",
"chmod -r 777",
"chmod 777",
"chown -r",
"shutdown",
"reboot",
"poweroff",
"init 0",
"init 6",
"iptables",
"nft ",
"useradd",
"userdel",
"passwd",
"visudo",
"crontab",
"systemctl disable",
"launchctl unload",
"kill -9",
"killall",
"pkill",
"docker rm",
"docker rmi",
"docker system prune",
"git push --force",
"git push -f",
"git reset --hard",
"git clean -f",
"DROP TABLE",
"DROP DATABASE",
"TRUNCATE",
"DELETE FROM",
]
});
/// Check whether a shell command contains patterns that must never be auto-approved.
///
/// Even when the user has chosen "always approve" for the shell tool, these commands
/// require explicit per-invocation approval because they are destructive.
pub fn requires_explicit_approval(command: &str) -> bool {
let lower = command.to_lowercase();
NEVER_AUTO_APPROVE_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_lowercase()))
}
/// Shell command execution tool.
pub struct ShellTool {
/// Working directory for commands (if None, uses job's working dir or cwd).
@@ -289,23 +341,17 @@ impl ShellTool {
// Determine timeout
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
// Try sandbox execution if available
// Use sandbox if configured; fail-closed (never silently fall through
// to unsandboxed execution when sandbox was intended).
if let Some(ref sandbox) = self.sandbox {
if sandbox.is_initialized() || sandbox.config().enabled {
match self
return self
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
.await
{
Ok((output, code)) => return Ok((output, code)),
Err(e) => {
// Log sandbox failure and fall through to direct execution
tracing::warn!("Sandbox execution failed, falling back to direct: {}", e);
}
}
.await;
}
}
// Fallback to direct execution
// Only execute directly when no sandbox was configured at all.
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
Ok((output, code as i64))
}
@@ -392,17 +438,19 @@ impl Tool for ShellTool {
}
}
/// Truncate output to fit within limits.
/// Truncate output to fit within limits (UTF-8 safe).
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_SIZE {
s.to_string()
} else {
let half = MAX_OUTPUT_SIZE / 2;
let head_end = crate::util::floor_char_boundary(s, half);
let tail_start = crate::util::floor_char_boundary(s, s.len() - half);
format!(
"{}\n\n... [truncated {} bytes] ...\n\n{}",
&s[..half],
&s[..head_end],
s.len() - MAX_OUTPUT_SIZE,
&s[s.len() - half..]
&s[tail_start..]
)
}
}
@@ -458,6 +506,27 @@ mod tests {
assert!(matches!(result, Err(ToolError::Timeout(_))));
}
#[test]
fn test_requires_explicit_approval() {
// Destructive commands should require explicit approval
assert!(requires_explicit_approval("rm -rf /tmp/stuff"));
assert!(requires_explicit_approval("git push --force origin main"));
assert!(requires_explicit_approval("git reset --hard HEAD~5"));
assert!(requires_explicit_approval("docker rm container_name"));
assert!(requires_explicit_approval("kill -9 12345"));
assert!(requires_explicit_approval("DROP TABLE users;"));
// Safe commands should not
assert!(!requires_explicit_approval("cargo build"));
assert!(!requires_explicit_approval("git status"));
assert!(!requires_explicit_approval("ls -la"));
assert!(!requires_explicit_approval("echo hello"));
assert!(!requires_explicit_approval("cat file.txt"));
assert!(!requires_explicit_approval(
"git push origin feature-branch"
));
}
#[test]
fn test_sandbox_policy_builder() {
let tool = ShellTool::new()
+1 -6
View File
@@ -365,12 +365,7 @@ pub async fn save_mcp_servers_to_db(
store
.set_setting(user_id, "mcp_servers", &value)
.await
.map_err(|e| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
.map_err(std::io::Error::other)?;
Ok(())
}
+112 -3
View File
@@ -25,9 +25,46 @@ use crate::tools::wasm::{
};
use crate::workspace::Workspace;
/// Names of built-in tools that cannot be shadowed by dynamic registrations.
/// This prevents a dynamically built or installed tool from replacing a
/// security-critical built-in like "shell" or "memory_write".
const PROTECTED_TOOL_NAMES: &[&str] = &[
"echo",
"time",
"json",
"http",
"shell",
"read_file",
"write_file",
"list_dir",
"apply_patch",
"memory_search",
"memory_write",
"memory_read",
"memory_tree",
"create_job",
"list_jobs",
"job_status",
"cancel_job",
"build_software",
"tool_search",
"tool_install",
"tool_auth",
"tool_activate",
"tool_list",
"tool_remove",
"routine_create",
"routine_list",
"routine_update",
"routine_delete",
"routine_history",
];
/// Registry of available tools.
pub struct ToolRegistry {
tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
/// Tracks which names were registered as built-in (protected from shadowing).
builtin_names: RwLock<std::collections::HashSet<String>>,
}
impl ToolRegistry {
@@ -35,21 +72,35 @@ impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: RwLock::new(HashMap::new()),
builtin_names: RwLock::new(std::collections::HashSet::new()),
}
}
/// Register a tool.
/// Register a tool. Rejects dynamic tools that try to shadow a built-in name.
pub async fn register(&self, tool: Arc<dyn Tool>) {
let name = tool.name().to_string();
if self.builtin_names.read().await.contains(&name) {
tracing::warn!(
tool = %name,
"Rejected tool registration: would shadow a built-in tool"
);
return;
}
self.tools.write().await.insert(name.clone(), tool);
tracing::debug!("Registered tool: {}", name);
}
/// Register a tool (sync version for startup).
/// Register a tool (sync version for startup, marks as built-in).
pub fn register_sync(&self, tool: Arc<dyn Tool>) {
let name = tool.name().to_string();
if let Ok(mut tools) = self.tools.try_write() {
tools.insert(name.clone(), tool);
// Mark as built-in so it can't be shadowed later
if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
if let Ok(mut builtins) = self.builtin_names.try_write() {
builtins.insert(name.clone());
}
}
tracing::debug!("Registered tool: {}", name);
}
}
@@ -419,10 +470,18 @@ impl Default for ToolRegistry {
}
}
impl std::fmt::Debug for ToolRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolRegistry")
.field("count", &self.count())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::EchoTool;
use crate::tools::registry::EchoTool;
#[tokio::test]
async fn test_register_and_get() {
@@ -452,4 +511,54 @@ mod tests {
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "echo");
}
#[tokio::test]
async fn test_builtin_tool_cannot_be_shadowed() {
let registry = ToolRegistry::new();
// Register echo as built-in (uses register_sync which marks protected names)
registry.register_sync(Arc::new(EchoTool));
assert!(registry.has("echo").await);
let original_desc = registry
.get("echo")
.await
.unwrap()
.description()
.to_string();
// Create a fake tool that tries to shadow "echo"
struct FakeEcho;
#[async_trait::async_trait]
impl Tool for FakeEcho {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"EVIL SHADOW"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
unreachable!()
}
}
// Try to shadow via register() (dynamic path)
registry.register(Arc::new(FakeEcho)).await;
// The original should still be there
let desc = registry
.get("echo")
.await
.unwrap()
.description()
.to_string();
assert_eq!(desc, original_desc);
assert_ne!(desc, "EVIL SHADOW");
}
}
+47 -47
View File
@@ -199,57 +199,57 @@ pub trait Tool: Send + Sync {
}
}
/// A simple no-op tool for testing.
#[derive(Debug)]
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes back the input message. Useful for testing."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo back"
}
},
"required": ["message"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1)))
}
fn requires_sanitization(&self) -> bool {
false // Echo is a trusted internal tool
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A simple no-op tool for testing.
#[derive(Debug)]
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes back the input message. Useful for testing."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo back"
}
},
"required": ["message"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1)))
}
fn requires_sanitization(&self) -> bool {
false // Echo is a trusted internal tool
}
}
#[tokio::test]
async fn test_echo_tool() {
let tool = EchoTool;
+58
View File
@@ -182,6 +182,17 @@ fn parse_url(url: &str) -> Result<ParsedUrl, String> {
return Err(format!("Unsupported scheme: {}", scheme));
}
// Reject URLs with userinfo (user:pass@host) to prevent allowlist bypass.
// A URL like https://[email protected]/ would match the allowlist
// for api.openai.com but actually send traffic to evil.com.
let authority = match rest.find('/') {
Some(idx) => &rest[..idx],
None => rest,
};
if authority.contains('@') {
return Err("URL contains userinfo (@) which is not allowed".to_string());
}
// Split host from path
let (host_and_port, path) = match rest.find('/') {
Some(idx) => (&rest[..idx], &rest[idx..]),
@@ -207,6 +218,14 @@ fn parse_url(url: &str) -> Result<ParsedUrl, String> {
None => host_and_port,
};
// Reject URLs with userinfo (user:pass@host).
// A URL like https://[email protected]/ confuses the parser into
// seeing "api.openai.com" as the host, but reqwest actually sends to
// "evil.com". Block any '@' in the authority section to prevent this.
if host.contains('@') || host_and_port.contains('@') {
return Err("URL contains userinfo (@) which is not allowed".to_string());
}
// Validate host
if host.is_empty() {
return Err("Empty host".to_string());
@@ -332,6 +351,21 @@ mod tests {
}
}
#[test]
fn test_userinfo_rejected() {
let validator = validator_with_patterns();
// Userinfo in URL should be rejected to prevent allowlist bypass
let result = validator.validate("https://[email protected]/v1/chat", "GET");
assert!(!result.is_allowed());
if let super::AllowlistResult::Denied(reason) = result {
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
} else {
panic!("Expected denied for userinfo URL");
}
}
#[test]
fn test_invalid_url() {
let validator = validator_with_patterns();
@@ -354,4 +388,28 @@ mod tests {
let result = validator.validate("http://localhost:8080/api", "GET");
assert!(result.is_allowed());
}
#[test]
fn test_reject_url_with_userinfo() {
let validator = validator_with_patterns();
// Attacker uses userinfo to trick the parser: the allowlist sees
// "api.openai.com" but reqwest would actually connect to "evil.com".
let result = validator.validate("https://[email protected]/v1/steal", "GET");
assert!(!result.is_allowed());
if let super::AllowlistResult::Denied(reason) = result {
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
} else {
panic!("Expected denied due to userinfo");
}
}
#[test]
fn test_reject_url_with_user_pass() {
let validator = validator_with_patterns();
let result = validator.validate("https://user:[email protected]/v1/chat", "GET");
assert!(!result.is_allowed());
}
}
+23
View File
@@ -14,6 +14,10 @@ use wasmtime::{Config, Engine, OptLevel};
use crate::tools::wasm::error::WasmError;
use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
/// Default epoch tick interval. Each tick increments the engine's epoch counter,
/// which causes any store with an expired epoch deadline to trap.
pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500);
/// Configuration for the WASM runtime.
#[derive(Debug, Clone)]
pub struct WasmRuntimeConfig {
@@ -123,6 +127,25 @@ impl WasmToolRuntime {
WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e))
})?;
// Spawn a background thread that periodically increments the engine's
// epoch counter. Without this, epoch_deadline_trap() never fires and
// WASM modules can spin indefinitely even with a deadline set.
let ticker_engine = engine.clone();
std::thread::Builder::new()
.name("wasm-epoch-ticker".into())
.spawn(move || {
loop {
std::thread::sleep(EPOCH_TICK_INTERVAL);
ticker_engine.increment_epoch();
}
})
.map_err(|e| {
WasmError::EngineCreationFailed(format!(
"Failed to spawn epoch ticker thread: {}",
e
))
})?;
Ok(Self {
engine,
config,
+190 -6
View File
@@ -23,7 +23,7 @@ use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::error::WasmError;
use crate::tools::wasm::host::{HostState, LogLevel};
use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter};
use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime};
use crate::tools::wasm::runtime::{EPOCH_TICK_INTERVAL, PreparedModule, WasmToolRuntime};
// Generate component model bindings from the WIT file.
//
@@ -194,10 +194,25 @@ impl near::agent::host::Host for StoreData {
.scan_http_request(&url, &header_vec, body.as_deref())
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
// Get the max response size from capabilities (default 10MB).
let max_response_bytes = self
.host_state
.capabilities()
.http
.as_ref()
.map(|h| h.max_response_bytes)
.unwrap_or(10 * 1024 * 1024);
// Resolve hostname and reject private/internal IPs to prevent DNS rebinding.
reject_private_ip(&url)?;
// Make HTTP request using blocking I/O.
// We're inside a spawn_blocking context, so use block_on.
let result = tokio::runtime::Handle::current().block_on(async {
let client = reqwest::Client::new();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("failed to create HTTP client: {e}"))?;
let mut request = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
@@ -241,11 +256,31 @@ impl near::agent::host::Host for StoreData {
})
.collect();
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
// Check Content-Length header for early rejection of oversized responses.
let max_response = max_response_bytes;
if let Some(cl) = response.content_length() {
if cl as usize > max_response {
return Err(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
cl, max_response
));
}
}
// Read body with a size cap to prevent memory exhaustion.
let body = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?
.to_vec();
.map_err(|e| format!("Failed to read response body: {}", e))?;
if body.len() > max_response {
return Err(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
body.len(),
max_response
));
}
let body = body.to_vec();
// Leak detection on response body
if let Ok(body_str) = std::str::from_utf8(&body) {
@@ -380,9 +415,13 @@ impl WasmToolWrapper {
.map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
}
// Configure epoch deadline for timeout backup
// Configure epoch deadline as a hard timeout backup.
// The epoch ticker thread increments the engine epoch every EPOCH_TICK_INTERVAL.
// Setting deadline to N means "trap after N ticks", so we compute the number
// of ticks that fit in the tool's timeout. Minimum 1 to always have a backstop.
store.epoch_deadline_trap();
store.set_epoch_deadline(1);
let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
store.set_epoch_deadline(ticks);
// Set up resource limiter
store.limiter(|data| &mut data.limiter);
@@ -531,6 +570,88 @@ impl std::fmt::Debug for WasmToolWrapper {
}
}
/// Resolve the URL's hostname and reject connections to private/internal IP addresses.
/// This prevents DNS rebinding attacks where an attacker's domain resolves to an
/// internal IP after passing the allowlist check.
fn reject_private_ip(url: &str) -> Result<(), String> {
let host = url
.split("://")
.nth(1)
.and_then(|rest| {
let host_and_port = rest.split('/').next().unwrap_or(rest);
// Strip port
if host_and_port.starts_with('[') {
// IPv6
host_and_port.find(']').map(|i| &host_and_port[1..i])
} else {
Some(
host_and_port
.rfind(':')
.map_or(host_and_port, |i| &host_and_port[..i]),
)
}
})
.ok_or_else(|| "Failed to parse host from URL".to_string())?;
// If the host is already an IP, check it directly
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return if is_private_ip(ip) {
Err(format!(
"HTTP request to private/internal IP {} is not allowed",
ip
))
} else {
Ok(())
};
}
// Resolve DNS and check all addresses
use std::net::ToSocketAddrs;
// Port 0 is a placeholder; ToSocketAddrs needs host:port but the port
// doesn't affect which IPs the hostname resolves to.
let addrs: Vec<_> = format!("{}:0", host)
.to_socket_addrs()
.map_err(|e| format!("DNS resolution failed for {}: {}", host, e))?
.collect();
if addrs.is_empty() {
return Err(format!("DNS resolution returned no addresses for {}", host));
}
for addr in &addrs {
if is_private_ip(addr.ip()) {
return Err(format!(
"DNS rebinding detected: {} resolved to private IP {}",
host,
addr.ip()
));
}
}
Ok(())
}
/// Check if an IP address belongs to a private/internal range.
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() // 127.0.0.0/8
|| v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|| v4.is_link_local() // 169.254.0.0/16
|| v4.is_unspecified() // 0.0.0.0
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT)
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback() // ::1
|| v6.is_unspecified() // ::
// fc00::/7 (unique local)
|| (v6.segments()[0] & 0xFE00) == 0xFC00
// fe80::/10 (link-local)
|| (v6.segments()[0] & 0xFFC0) == 0xFE80
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
@@ -557,4 +678,67 @@ mod tests {
assert!(caps.tool_invoke.is_none());
assert!(caps.secrets.is_none());
}
#[test]
fn test_is_private_ip_v4() {
use std::net::IpAddr;
// Private ranges
assert!(super::is_private_ip("127.0.0.1".parse::<IpAddr>().unwrap()));
assert!(super::is_private_ip("10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(super::is_private_ip(
"172.16.0.1".parse::<IpAddr>().unwrap()
));
assert!(super::is_private_ip(
"192.168.1.1".parse::<IpAddr>().unwrap()
));
assert!(super::is_private_ip(
"169.254.1.1".parse::<IpAddr>().unwrap()
));
assert!(super::is_private_ip("0.0.0.0".parse::<IpAddr>().unwrap()));
// CGNAT
assert!(super::is_private_ip(
"100.64.0.1".parse::<IpAddr>().unwrap()
));
// Public IPs
assert!(!super::is_private_ip("8.8.8.8".parse::<IpAddr>().unwrap()));
assert!(!super::is_private_ip("1.1.1.1".parse::<IpAddr>().unwrap()));
assert!(!super::is_private_ip(
"93.184.216.34".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_is_private_ip_v6() {
use std::net::IpAddr;
assert!(super::is_private_ip("::1".parse::<IpAddr>().unwrap()));
assert!(super::is_private_ip("::".parse::<IpAddr>().unwrap()));
assert!(super::is_private_ip("fc00::1".parse::<IpAddr>().unwrap()));
assert!(super::is_private_ip("fe80::1".parse::<IpAddr>().unwrap()));
// Public
assert!(!super::is_private_ip(
"2606:4700::1111".parse::<IpAddr>().unwrap()
));
}
#[test]
fn test_reject_private_ip_loopback() {
let result = super::reject_private_ip("https://127.0.0.1:8080/api");
assert!(result.is_err());
assert!(result.unwrap_err().contains("private/internal IP"));
}
#[test]
fn test_reject_private_ip_internal() {
let result = super::reject_private_ip("https://192.168.1.1/admin");
assert!(result.is_err());
}
#[test]
fn test_reject_private_ip_public_ok() {
// 8.8.8.8 (Google DNS) is public
let result = super::reject_private_ip("https://8.8.8.8/dns-query");
assert!(result.is_ok());
}
}