use crate::api::MorphClient;
use crate::config::SandboxMode;
use crate::error::{DEFAULT_PARENT_DIR, Result, SofosError};
use crate::mcp::McpManager;
use crate::mcp::manager::{ImageData, ToolResult as McpToolResult};
use crate::tools::ToolName;
use crate::tools::bash::BashExecutor;
use crate::tools::codesearch::CodeSearchTool;
use crate::tools::filesystem::FileSystemTool;
use crate::tools::image::ImageLoader;
use crate::tools::morph_validate;
use crate::tools::permissions::{self, PermissionManager};
use crate::tools::plan;
use crate::tools::resolve::ResolvedPath;
use crate::tools::types::{
add_code_search_tool, get_all_tools, get_all_tools_with_morph, get_read_only_tools,
};
use crate::tools::utils::{
MAX_DIFF_TOKENS, MAX_FILE_READ_TOKENS, MAX_MCP_IMAGE_BYTES, MAX_MCP_IMAGE_COUNT,
MAX_MCP_OUTPUT_TOKENS, MAX_PATH_LIST_TOKENS, TruncationKind, base64_approx_decoded_kb,
confirm_destructive, is_http_url, truncate_for_context,
};
use crate::ui::diff;
use colored::Colorize;
use serde_json::Value;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const SOFOS_USER_AGENT: &str = concat!("Sofos/", env!("CARGO_PKG_VERSION"));
const MAX_WEB_FETCH_BODY_BYTES: usize = 8 * 1024 * 1024;
const WEB_FETCH_TEXT_BUDGET_BYTES: usize = 128 * 1024;
const MAX_WEB_FETCH_REDIRECTS: usize = 3;
const WEB_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
fn is_web_fetch_redirect(status: reqwest::StatusCode) -> bool {
use reqwest::StatusCode;
matches!(
status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::SEE_OTHER
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
)
}
fn web_fetch_redirect_target(current: &reqwest::Url, location: &str) -> Result<reqwest::Url> {
let next = current.join(location).map_err(|_| {
SofosError::ToolExecution(format!(
"web_fetch could not resolve the redirect target '{}'",
location
))
})?;
if !matches!(next.scheme(), "http" | "https") {
return Err(SofosError::ToolExecution(
"web_fetch refuses to follow a non-http(s) redirect".to_string(),
));
}
Ok(next)
}
fn redirect_denied(requested_host: &str, target_host: &str, denial: SofosError) -> SofosError {
let reason = match denial {
SofosError::ToolExecution(message) => message,
other => other.to_string(),
};
SofosError::ToolExecution(format!(
"web_fetch to '{requested_host}' was redirected to '{target_host}'. {reason}"
))
}
#[derive(Debug, Clone)]
pub enum ToolExecutionResult {
Text(String),
TextWithDisplay { text: String, display: String },
Structured(McpToolResult),
}
impl ToolExecutionResult {
pub fn text(&self) -> &str {
match self {
ToolExecutionResult::Text(s) => s,
ToolExecutionResult::TextWithDisplay { text, .. } => text,
ToolExecutionResult::Structured(r) => &r.text,
}
}
pub fn display_text(&self) -> &str {
match self {
ToolExecutionResult::Text(s) => s,
ToolExecutionResult::TextWithDisplay { display, .. } => display,
ToolExecutionResult::Structured(r) => &r.text,
}
}
pub fn images(&self) -> &[ImageData] {
match self {
ToolExecutionResult::Text(_) => &[],
ToolExecutionResult::TextWithDisplay { .. } => &[],
ToolExecutionResult::Structured(r) => &r.images,
}
}
}
fn resolve_view_image_candidate(workspace: &std::path::Path, path: &str) -> std::path::PathBuf {
if crate::tools::utils::is_absolute_or_tilde(path) {
std::path::PathBuf::from(PermissionManager::expand_tilde_pub(path))
} else {
workspace.join(path)
}
}
impl From<crate::tools::image::ImageSource> for ImageData {
fn from(source: crate::tools::image::ImageSource) -> Self {
match source {
crate::tools::image::ImageSource::Base64 { media_type, data } => ImageData::Base64 {
mime_type: media_type,
data,
},
crate::tools::image::ImageSource::Url { url } => ImageData::Url { url },
}
}
}
const FILE_MUTATION_SUMMARY_HEADER: &str = "Success. Updated the following files:";
fn file_modification_result(
path: &str,
original: &str,
modified: &str,
success_prefix: &str,
) -> ToolExecutionResult {
let diff_output = diff::generate_compact_diff(original, modified, path);
let display_body = format!("{} '{}'\n\nChanges:\n{}", success_prefix, path, diff_output);
let display = truncate_for_context(&display_body, MAX_DIFF_TOKENS, TruncationKind::DiffOutput);
let summary = format!("{FILE_MUTATION_SUMMARY_HEADER}\nM {path}");
ToolExecutionResult::TextWithDisplay {
text: summary,
display,
}
}
#[derive(Clone)]
pub struct ToolExecutor {
pub(super) fs_tool: FileSystemTool,
code_search_tool: Option<CodeSearchTool>,
bash_executor: BashExecutor,
morph_client: Option<MorphClient>,
mcp_manager: Option<McpManager>,
image_loader: Arc<ImageLoader>,
mode: SandboxMode,
interactive: bool,
read_path_session_allowed: Arc<Mutex<HashSet<String>>>,
read_path_session_denied: Arc<Mutex<HashSet<String>>>,
write_path_session_allowed: Arc<Mutex<HashSet<String>>>,
write_path_session_denied: Arc<Mutex<HashSet<String>>>,
web_fetch_session_allowed: Arc<Mutex<HashSet<String>>>,
web_fetch_session_denied: Arc<Mutex<HashSet<String>>>,
mcp_session_allowed: Arc<Mutex<HashSet<String>>>,
mcp_session_denied: Arc<Mutex<HashSet<String>>>,
}
pub(super) fn cap_mcp_response(result: &mut McpToolResult) {
let dropped = cap_mcp_images(result);
result.text = truncate_for_context(
&result.text,
MAX_MCP_OUTPUT_TOKENS,
TruncationKind::McpOutput,
);
if dropped > 0 {
result.text.push_str(&format!(
"\n\n[{} image attachment(s) dropped: MCP image cap is {} images or ~{} MB total]",
dropped,
MAX_MCP_IMAGE_COUNT,
MAX_MCP_IMAGE_BYTES / (1024 * 1024)
));
}
}
pub(super) fn cap_mcp_images(result: &mut McpToolResult) -> usize {
let original = result.images.len();
let mut kept = Vec::with_capacity(result.images.len().min(MAX_MCP_IMAGE_COUNT));
let mut total_bytes: usize = 0;
for img in std::mem::take(&mut result.images) {
let size = img.outbound_size();
if kept.len() >= MAX_MCP_IMAGE_COUNT
|| total_bytes.saturating_add(size) > MAX_MCP_IMAGE_BYTES
{
continue;
}
total_bytes += size;
kept.push(img);
}
result.images = kept;
original - result.images.len()
}
fn ensure_parent_dir(path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
SofosError::ToolExecution(format!(
"Failed to create destination parent '{}': {}",
parent.display(),
e
))
})?;
}
}
Ok(())
}
fn move_between(
source: &str,
destination: &str,
src: &ResolvedPath,
dst: &ResolvedPath,
fs_tool: &FileSystemTool,
) -> Result<()> {
if src.is_inside_workspace && dst.is_inside_workspace {
return fs_tool.move_file(source, destination);
}
ensure_parent_dir(&dst.canonical)?;
crate::tools::filesystem::rename_with_cross_device_fallback(&src.canonical, &dst.canonical)
.map_err(|e| {
SofosError::ToolExecution(format!(
"Failed to move '{}' to '{}': {}",
source, destination, e
))
})
}
fn copy_between(
source: &str,
destination: &str,
src: &ResolvedPath,
dst: &ResolvedPath,
fs_tool: &FileSystemTool,
) -> Result<()> {
if src.is_inside_workspace && dst.is_inside_workspace {
return fs_tool.copy_file(source, destination);
}
ensure_parent_dir(&dst.canonical)?;
std::fs::copy(&src.canonical, &dst.canonical)
.map(|_| ())
.map_err(|e| {
SofosError::ToolExecution(format!(
"Failed to copy '{}' to '{}': {}",
source, destination, e
))
})
}
impl ToolExecutor {
pub fn new(
workspace: std::path::PathBuf,
morph_client: Option<MorphClient>,
mcp_manager: Option<McpManager>,
mode: SandboxMode,
interactive: bool,
) -> Result<Self> {
let code_search_tool = match CodeSearchTool::new(workspace.clone()) {
Ok(tool) => Some(tool),
Err(_) => {
crate::ui::UI::print_warning("ripgrep not found. Code search will be unavailable.");
None
}
};
let has_morph = morph_client.is_some();
let read_path_session_allowed = Arc::new(Mutex::new(HashSet::new()));
let read_path_session_denied = Arc::new(Mutex::new(HashSet::new()));
let mut image_loader = ImageLoader::new(workspace.clone())?;
image_loader.install_read_path_session(
interactive,
Arc::clone(&read_path_session_allowed),
Arc::clone(&read_path_session_denied),
);
let mut bash_executor = BashExecutor::new(workspace.clone(), interactive, has_morph)?;
bash_executor.set_sandbox_mode(mode);
Ok(Self {
fs_tool: FileSystemTool::new(workspace.clone())?,
code_search_tool,
bash_executor,
morph_client,
mcp_manager,
image_loader: Arc::new(image_loader),
mode,
interactive,
read_path_session_allowed,
read_path_session_denied,
write_path_session_allowed: Arc::new(Mutex::new(HashSet::new())),
write_path_session_denied: Arc::new(Mutex::new(HashSet::new())),
web_fetch_session_allowed: Arc::new(Mutex::new(HashSet::new())),
web_fetch_session_denied: Arc::new(Mutex::new(HashSet::new())),
mcp_session_allowed: Arc::new(Mutex::new(HashSet::new())),
mcp_session_denied: Arc::new(Mutex::new(HashSet::new())),
})
}
async fn web_fetch_follow_redirects(
&self,
client: &reqwest::Client,
url: reqwest::Url,
) -> Result<reqwest::Response> {
let requested_host = url.host_str().unwrap_or_default().to_string();
let mut deadline = Instant::now() + WEB_FETCH_TIMEOUT;
let mut current = url;
let mut redirects = 0;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(SofosError::ToolExecution(format!(
"web_fetch timed out after {} seconds",
WEB_FETCH_TIMEOUT.as_secs()
)));
}
let response = client
.get(current.clone())
.timeout(remaining)
.header("User-Agent", SOFOS_USER_AGENT)
.send()
.await
.map_err(|e| SofosError::ToolExecution(format!("Fetch failed: {}", e)))?;
if !is_web_fetch_redirect(response.status()) {
return Ok(response);
}
if redirects >= MAX_WEB_FETCH_REDIRECTS {
return Err(SofosError::ToolExecution(format!(
"web_fetch follows at most {} redirects",
MAX_WEB_FETCH_REDIRECTS
)));
}
redirects += 1;
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
SofosError::ToolExecution(
"web_fetch received a redirect with no readable Location header"
.to_string(),
)
})?;
let next = web_fetch_redirect_target(¤t, location)?;
if next.host_str() != current.host_str() {
let next_host = next.host_str().ok_or_else(|| {
SofosError::ToolExecution(
"web_fetch could not determine the host of the redirect target".to_string(),
)
})?;
let gate_started = Instant::now();
permissions::check_web_fetch_session_access(
self.fs_tool.workspace(),
next_host,
self.interactive,
&self.web_fetch_session_allowed,
&self.web_fetch_session_denied,
)
.map_err(|denial| redirect_denied(&requested_host, next_host, denial))?;
deadline += gate_started.elapsed();
}
current = next;
}
}
pub fn has_morph(&self) -> bool {
self.morph_client.is_some()
}
pub fn has_code_search(&self) -> bool {
self.code_search_tool.is_some()
}
pub fn set_mode(&mut self, mode: SandboxMode) {
self.mode = mode;
self.bash_executor.set_sandbox_mode(mode);
}
pub fn set_approval_policy(&mut self, policy: crate::config::ApprovalPolicy) {
self.bash_executor.set_approval_policy(policy);
}
pub fn mcp_servers_excluded_from_readonly(&self) -> Vec<String> {
self.mcp_manager
.as_ref()
.map(|m| m.server_names_for_readonly(false))
.unwrap_or_default()
}
pub fn mcp_servers_included_in_readonly(&self) -> Vec<String> {
self.mcp_manager
.as_ref()
.map(|m| m.server_names_for_readonly(true))
.unwrap_or_default()
}
pub fn install_interrupt_flag(&mut self, flag: Arc<std::sync::atomic::AtomicBool>) {
self.bash_executor.install_interrupt_flag(flag);
}
fn check_read_access(
&self,
path: &str,
canonical: &std::path::Path,
canonical_str: &str,
is_inside_workspace: bool,
) -> Result<()> {
let permission_manager = PermissionManager::new(self.fs_tool.workspace().to_path_buf())?;
let (perm_original, matched_rule_original) =
permission_manager.check_read_permission_with_source(path);
let (perm_canonical, matched_rule_canonical) =
permission_manager.check_read_permission_with_source(canonical_str);
let (final_perm, matched_rule) = if perm_original == permissions::CommandPermission::Denied
{
(perm_original, matched_rule_original)
} else if perm_canonical == permissions::CommandPermission::Denied {
(perm_canonical, matched_rule_canonical)
} else if perm_original == permissions::CommandPermission::Ask {
(perm_original, None)
} else if perm_canonical == permissions::CommandPermission::Ask {
(perm_canonical, None)
} else {
(permissions::CommandPermission::Allowed, None)
};
match final_perm {
permissions::CommandPermission::Denied => {
let config_source = if let Some(ref rule) = matched_rule {
permission_manager.get_rule_source(rule)
} else {
crate::config::config_files_hint()
};
return Err(SofosError::ToolExecution(format!(
"Read access denied for path '{}'\n\
Hint: Blocked by deny rule in {}",
path, config_source
)));
}
permissions::CommandPermission::Ask => {
return Err(SofosError::ToolExecution(format!(
"Path '{}' is in 'ask' list\n\
Hint: 'ask' only works for Bash commands. Use 'allow' or 'deny' for Read permissions.",
path
)));
}
permissions::CommandPermission::Allowed => {}
}
if !is_inside_workspace {
let is_explicit_allow = permission_manager.is_read_explicit_allow(canonical_str);
if !is_explicit_allow {
let dir_to_grant = if canonical.is_dir() {
canonical_str.to_string()
} else {
permissions::grant_dir_for_path(canonical).to_string()
};
self.check_external_path_access(
"Read",
canonical_str,
&dir_to_grant,
&self.read_path_session_allowed,
&self.read_path_session_denied,
)?;
}
}
Ok(())
}
fn check_write_access(
&self,
path: &str,
canonical_str: &str,
canonical: &std::path::Path,
) -> Result<()> {
let permission_manager = PermissionManager::new(self.fs_tool.workspace().to_path_buf())?;
if permission_manager.check_write_permission(canonical_str)
== permissions::CommandPermission::Denied
{
return Err(SofosError::ToolExecution(format!(
"Write access denied for path '{}'\n\
Hint: Blocked by deny rule in {}",
path,
crate::config::config_files_hint()
)));
}
let is_explicit_allow = permission_manager.is_write_explicit_allow(canonical_str);
if !is_explicit_allow {
let dir_to_grant = permissions::grant_dir_for_path(canonical);
self.check_external_path_access(
"Write",
canonical_str,
dir_to_grant,
&self.write_path_session_allowed,
&self.write_path_session_denied,
)?;
}
Ok(())
}
fn check_external_path_access(
&self,
scope: &str,
canonical_path: &str,
dir_to_grant: &str,
session_allowed: &Arc<Mutex<HashSet<String>>>,
session_denied: &Arc<Mutex<HashSet<String>>>,
) -> Result<()> {
permissions::check_external_path_session_access(
self.fs_tool.workspace(),
scope,
canonical_path,
dir_to_grant,
self.interactive,
session_allowed,
session_denied,
)
}
pub async fn get_available_tools(&self) -> Vec<crate::api::Tool> {
let mut tools = if self.mode.is_readonly() {
get_read_only_tools()
} else if self.has_morph() {
get_all_tools_with_morph()
} else {
get_all_tools()
};
if self.has_code_search() {
add_code_search_tool(&mut tools);
}
if let Some(mcp_manager) = &self.mcp_manager {
let mcp_tools = if self.mode.is_readonly() {
mcp_manager.get_readonly_tools().await
} else {
mcp_manager.get_all_tools().await
};
if let Ok(mcp_tools) = mcp_tools {
tools.extend(mcp_tools);
}
}
tools
}
pub async fn execute(&self, tool_name: &str, input: &Value) -> Result<ToolExecutionResult> {
if let Some(mcp_manager) = &self.mcp_manager {
if mcp_manager.is_mcp_tool(tool_name) {
let server = mcp_manager.server_for_tool(tool_name).ok_or_else(|| {
SofosError::ToolExecution(format!("Unknown MCP tool: {}", tool_name))
})?;
if self.mode.is_readonly() && !mcp_manager.is_server_available_in_readonly(server) {
return Err(SofosError::ToolExecution(format!(
"MCP tool '{}' is filtered out in read-only mode because its server is not marked for read-only access.",
tool_name
)));
}
let bare_tool = crate::mcp::manager::bare_tool_name(server, tool_name);
permissions::check_mcp_session_access(
self.fs_tool.workspace(),
server,
bare_tool,
self.interactive,
&self.mcp_session_allowed,
&self.mcp_session_denied,
)?;
let mut result = mcp_manager.execute_tool(tool_name, input).await?;
cap_mcp_response(&mut result);
return Ok(ToolExecutionResult::Structured(result));
}
}
let tool = ToolName::from_str(tool_name)?;
if self.mode.is_readonly() && !tool.is_read_only_safe() {
return Err(SofosError::ToolExecution(format!(
"Tool '{}' is not available in read-only mode, which allows inspection only — no edits or shell commands.",
tool
)));
}
let text_result = match tool {
ToolName::ReadFile => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let resolved = self.resolve_existing(path).map_err(|_| {
let parent_dir = std::path::Path::new(path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or(DEFAULT_PARENT_DIR);
SofosError::ToolExecution(format!(
"File not found: '{}'. Suggestion: Use list_directory with path '{}' to see available files.",
path, parent_dir
))
})?;
self.check_read_access(
path,
&resolved.canonical,
&resolved.canonical_str,
resolved.is_inside_workspace,
)?;
let raw = if resolved.is_inside_workspace {
self.fs_tool.read_file(path)?
} else {
self.fs_tool
.read_file_with_outside_access(&resolved.canonical_str)?
};
let content =
truncate_for_context(&raw, MAX_FILE_READ_TOKENS, TruncationKind::File);
Ok(crate::tools::format_read_file_output(path, &content))
}
ToolName::WriteFile => {
let path = input["path"]
.as_str()
.or_else(|| input["file_path"].as_str())
.or_else(|| input["file"].as_str())
.or_else(|| input["filepath"].as_str())
.or_else(|| input["filename"].as_str())
.ok_or_else(|| {
let keys: Vec<&String> = input
.as_object()
.map(|o| o.keys().collect())
.unwrap_or_default();
let content_only = keys.len() == 1
&& keys.first().map(|s| s.as_str()) == Some("content");
let hint = if content_only {
" Your previous response was likely truncated mid-call (content was emitted but the tool-call JSON was cut off before `path`). Split the file into smaller pieces, or use `edit_file` to append in chunks, rather than writing the full body in one call."
} else {
""
};
SofosError::ToolExecution(format!(
"Missing 'path' parameter. Got keys: {:?}. \
Please retry with 'path' set to the destination file path.{}",
keys, hint
))
})?;
let content = input["content"]
.as_str()
.or_else(|| input["text"].as_str())
.or_else(|| input["body"].as_str())
.or_else(|| input["data"].as_str())
.ok_or_else(|| {
SofosError::ToolExecution(format!(
"Missing 'content' parameter. Got keys: {:?}. \
Please retry with 'content' set to the file body.",
input
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
.unwrap_or_default()
))
})?;
let resolved = self.resolve_for_write(path)?;
if !resolved.is_inside_workspace {
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
let append = input["append"].as_bool().unwrap_or(false);
let original_content = if append {
None
} else if resolved.is_inside_workspace {
self.fs_tool.read_file(path).ok()
} else {
self.fs_tool
.read_file_with_outside_access(&resolved.canonical_str)
.ok()
};
match (append, resolved.is_inside_workspace) {
(true, true) => self.fs_tool.append_file(path, content)?,
(true, false) => self
.fs_tool
.append_file_with_outside_access(&resolved.canonical_str, content)?,
(false, true) => self.fs_tool.write_file(path, content)?,
(false, false) => self
.fs_tool
.write_file_with_outside_access(&resolved.canonical_str, content)?,
}
if append {
Ok(format!(
"Successfully appended {} bytes to '{}'",
content.len(),
path
))
} else if let Some(original) = original_content {
return Ok(file_modification_result(
path,
&original,
content,
"Successfully wrote to file",
));
} else {
Ok(format!("Successfully created file '{}'", path))
}
}
ToolName::ListDirectory => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let resolved = self.resolve_existing(path)?;
self.check_read_access(
path,
&resolved.canonical,
&resolved.canonical_str,
resolved.is_inside_workspace,
)?;
let entries = if resolved.is_inside_workspace {
self.fs_tool.list_directory(path)?
} else {
let canonical_entries = std::fs::read_dir(&resolved.canonical)?;
let mut entries = Vec::new();
for entry in canonical_entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type()?.is_dir();
entries.push(if is_dir { format!("{}/", name) } else { name });
}
entries.sort();
entries
};
let body = format!("Contents of '{}':\n{}", path, entries.join("\n"));
Ok(truncate_for_context(
&body,
MAX_PATH_LIST_TOKENS,
TruncationKind::PathList,
))
}
ToolName::CreateDirectory => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let resolved = self.resolve_for_write(path)?;
if !resolved.is_inside_workspace {
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
if resolved.is_inside_workspace {
self.fs_tool.create_directory(path)?;
} else {
std::fs::create_dir_all(&resolved.canonical).map_err(|e| {
SofosError::ToolExecution(format!(
"Failed to create directory '{}': {}",
path, e
))
})?;
}
Ok(format!("Successfully created directory '{}'", path))
}
ToolName::SearchCode => {
let code_search = self.code_search_tool.as_ref()
.ok_or_else(|| SofosError::ToolExecution(
"Code search not available. Please install ripgrep: https://github.com/BurntSushi/ripgrep".to_string()
))?;
let pattern = input["pattern"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'pattern' parameter".to_string())
})?;
let file_type = input["file_type"].as_str();
let max_results = input["max_results"].as_u64().map(|n| n as usize);
let include_ignored = input["include_ignored"].as_bool().unwrap_or(false);
let results =
code_search.search(pattern, file_type, max_results, include_ignored)?;
Ok(format!(
"{}{}",
crate::tools::codesearch::SEARCH_RESULTS_PREFIX,
results
))
}
ToolName::GlobFiles => {
let pattern = input["pattern"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'pattern' parameter".to_string())
})?;
let base = input["path"].as_str().unwrap_or(".");
let include_ignored = input["include_ignored"].as_bool().unwrap_or(false);
let follow_symlinks = input["follow_symlinks"].as_bool().unwrap_or(false);
let resolved = self.resolve_existing(base)?;
self.check_read_access(
base,
&resolved.canonical,
&resolved.canonical_str,
resolved.is_inside_workspace,
)?;
let search_dir = resolved.canonical;
let glob = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map_err(|e| SofosError::ToolExecution(format!("Invalid glob pattern: {}", e)))?
.compile_matcher();
let excluded_basenames: std::collections::HashSet<&str> = if include_ignored {
std::collections::HashSet::new()
} else {
crate::tools::codesearch::DEFAULT_EXCLUDE_DIRS
.iter()
.copied()
.collect()
};
const GLOB_MAX_MATCHES: usize = 50_000;
let mut matches = Vec::new();
let mut stack = vec![search_dir.clone()];
let mut visited: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
if follow_symlinks {
if let Ok(canon) = std::fs::canonicalize(&search_dir) {
visited.insert(canon);
}
}
let mut hit_cap = false;
'walk: while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_symlink() && !follow_symlinks {
continue;
}
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if excluded_basenames.contains(dir_name) {
continue;
}
if follow_symlinks {
match std::fs::canonicalize(&path) {
Ok(canon) => {
if !visited.insert(canon) {
continue;
}
}
Err(_) => continue,
}
}
stack.push(path);
} else if let Ok(rel) = path.strip_prefix(&search_dir) {
let rel_str = rel.to_string_lossy();
if glob.is_match(rel_str.as_ref()) {
matches.push(rel_str.to_string());
if matches.len() >= GLOB_MAX_MATCHES {
hit_cap = true;
break 'walk;
}
}
}
}
}
matches.sort();
let body = if matches.is_empty() {
format!("No files matching '{}' in '{}'", pattern, base)
} else if hit_cap {
format!(
"Found {}+ file(s) matching '{}' (capped at {}; narrow the pattern to see the rest):\n{}",
matches.len(),
pattern,
GLOB_MAX_MATCHES,
matches.join("\n")
)
} else {
format!(
"Found {} file(s) matching '{}':\n{}",
matches.len(),
pattern,
matches.join("\n")
)
};
Ok(truncate_for_context(
&body,
MAX_PATH_LIST_TOKENS,
TruncationKind::PathList,
))
}
ToolName::EditFile => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let old_string = input["old_string"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'old_string' parameter".to_string())
})?;
let new_string = input["new_string"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'new_string' parameter".to_string())
})?;
let replace_all = input["replace_all"].as_bool().unwrap_or(false);
if old_string.is_empty() {
return Err(SofosError::ToolExecution(format!(
"old_string cannot be empty for '{}'. Use read_file to copy the exact current text you want to replace.",
path
)));
}
let truncation_markers = [
"...[truncated",
"// ... existing code ...",
"/* ... existing code ... */",
"# ... existing code ...",
"<!-- ... existing code ... -->",
"[... existing code ...]",
"{# ... existing code ... #}",
];
for marker in &truncation_markers {
if old_string.contains(marker) {
return Err(SofosError::ToolExecution(format!(
"old_string contains a truncation marker '{}'. This is not real file content. \
Use read_file to get the actual current content of '{}' before editing.",
marker, path
)));
}
if new_string.contains(marker) {
return Err(SofosError::ToolExecution(format!(
"new_string contains a truncation marker '{}'. You must provide the complete \
replacement text, not abbreviated content. Use read_file to get the current \
content of '{}' if needed.",
marker, path
)));
}
}
let resolved = self.resolve_existing(path).map_err(|_| {
SofosError::ToolExecution(format!(
"File not found: '{}'. The file must exist to edit it.",
path
))
})?;
if !resolved.is_inside_workspace {
self.check_read_access(
path,
&resolved.canonical,
&resolved.canonical_str,
resolved.is_inside_workspace,
)?;
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
let pre_meta = std::fs::metadata(&resolved.canonical).ok();
let original = if resolved.is_inside_workspace {
self.fs_tool.read_file(path)?
} else {
self.fs_tool
.read_file_with_outside_access(&resolved.canonical_str)?
};
let match_count = original.matches(old_string).count();
if match_count == 0 {
return Err(SofosError::ToolExecution(format!(
"old_string not found in '{}'. Make sure it matches the file content exactly, \
including whitespace and indentation. Use read_file first to see the current content.",
path
)));
}
if !replace_all && match_count > 1 {
return Err(SofosError::ToolExecution(format!(
"old_string appears {} times in '{}'. Non-global edit_file calls require a unique match so the wrong occurrence is not changed. Include more surrounding context in old_string, or set replace_all to true if every occurrence should change.",
match_count, path
)));
}
let modified = if replace_all {
original.replace(old_string, new_string)
} else {
original.replacen(old_string, new_string, 1)
};
if let Some(pre) = pre_meta.as_ref() {
if let Ok(post) = std::fs::metadata(&resolved.canonical) {
let mtime_changed = match (pre.modified(), post.modified()) {
(Ok(a), Ok(b)) => a != b,
_ => false,
};
if mtime_changed || pre.len() != post.len() {
return Err(SofosError::ToolExecution(format!(
"File '{}' changed on disk between the read and the write \
(concurrent edit?). Re-run read_file to see the current \
content and retry.",
path
)));
}
}
}
if resolved.is_inside_workspace {
self.fs_tool.write_file(path, &modified)?;
} else {
self.fs_tool
.write_file_with_outside_access(&resolved.canonical_str, &modified)?;
}
return Ok(file_modification_result(
path,
&original,
&modified,
"Successfully edited",
));
}
ToolName::MorphEditFile => {
let morph = self.morph_client.as_ref().ok_or_else(|| {
SofosError::ToolExecution(
"Morph client not available. Set MORPH_API_KEY to use morph_edit_file"
.to_string(),
)
})?;
let path = input["target_filepath"]
.as_str()
.or_else(|| input["path"].as_str())
.or_else(|| input["file_path"].as_str())
.or_else(|| input["file"].as_str())
.ok_or_else(|| {
SofosError::ToolExecution(format!(
"Missing 'target_filepath' parameter. Got keys: {:?}. \
Please retry with the 'target_filepath' parameter set to the file path.",
input
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
.unwrap_or_default()
))
})?;
let instruction = input["instructions"]
.as_str()
.or_else(|| input["instruction"].as_str())
.ok_or_else(|| {
SofosError::ToolExecution("Missing 'instructions' parameter".to_string())
})?;
let code_edit = input["code_edit"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'code_edit' parameter".to_string())
})?;
if code_edit.contains("...[truncated") {
return Err(SofosError::ToolExecution(format!(
"code_edit contains a truncation marker '...[truncated'. This is not real code. \
Use read_file to get the actual current content of '{}' before editing.",
path
)));
}
let resolved = self.resolve_existing(path).map_err(|_| {
SofosError::ToolExecution(format!(
"File not found: '{}'. The file must exist for morph_edit_file.",
path
))
})?;
if !resolved.is_inside_workspace {
self.check_read_access(
path,
&resolved.canonical,
&resolved.canonical_str,
resolved.is_inside_workspace,
)?;
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
let original_code = if resolved.is_inside_workspace {
self.fs_tool.read_file(path)?
} else {
self.fs_tool
.read_file_with_outside_access(&resolved.canonical_str)?
};
let morph_timeout = Duration::from_secs(600);
let merged_code = match tokio::time::timeout(
morph_timeout,
morph.apply_edit(instruction, &original_code, code_edit),
)
.await
{
Ok(Ok(code)) => code,
Err(_elapsed) => {
eprintln!(
" {} Morph API timed out after {}s, use edit_file instead",
"âš ".bright_yellow(),
morph_timeout.as_secs()
);
return Ok(ToolExecutionResult::Text(format!(
"morph_edit_file timed out after {}s. The file '{}' was NOT modified. \
Please use read_file to get the current file content, then use edit_file \
with exact old_string/new_string to make this change.",
morph_timeout.as_secs(),
path
)));
}
Ok(Err(e)) => {
let msg = match e {
SofosError::Api(m) | SofosError::NetworkError(m) => m,
SofosError::Http(err) => err.to_string(),
other => return Err(other),
};
eprintln!(
" {} Morph API failed ({}), use edit_file instead",
"âš ".bright_yellow(),
msg
);
return Ok(ToolExecutionResult::Text(format!(
"morph_edit_file failed ({}). The file '{}' was NOT modified. \
Please use read_file to get the current file content, then use edit_file \
with exact old_string/new_string to make this change.",
msg, path
)));
}
};
if let Err(reason) =
morph_validate::validate_morph_output(&original_code, &merged_code)
{
eprintln!(
" {} Morph output rejected ({}), use edit_file instead",
"âš ".bright_yellow(),
reason
);
return Ok(ToolExecutionResult::Text(format!(
"morph_edit_file rejected Morph's response ({}). The file '{}' was NOT modified. \
Please use read_file to get the current file content, then use edit_file \
with exact old_string/new_string to make this change.",
reason, path
)));
}
if resolved.is_inside_workspace {
self.fs_tool.write_file(path, &merged_code)?;
} else {
self.fs_tool
.write_file_with_outside_access(&resolved.canonical_str, &merged_code)?;
}
return Ok(file_modification_result(
path,
&original_code,
&merged_code,
"Successfully applied Morph edit to",
));
}
ToolName::DeleteFile => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let resolved = self.resolve_existing(path)?;
if !resolved.is_inside_workspace {
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
let confirmed = confirm_destructive(&format!("Delete file '{}'?", path))?;
if !confirmed {
return Ok(ToolExecutionResult::Text(format!(
"File deletion cancelled by user. The file '{}' was not deleted.",
path
)));
}
if resolved.is_inside_workspace {
self.fs_tool.delete_file(path)?;
} else {
self.fs_tool
.delete_file_with_outside_access(&resolved.canonical_str)?;
}
Ok(format!("Successfully deleted file '{}'", path))
}
ToolName::DeleteDirectory => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let resolved = self.resolve_existing(path)?;
if !resolved.is_inside_workspace {
self.check_write_access(path, &resolved.canonical_str, &resolved.canonical)?;
}
let confirmed = confirm_destructive(&format!(
"Delete directory '{}' and all its contents?",
path
))?;
if !confirmed {
return Ok(ToolExecutionResult::Text(format!(
"Directory deletion cancelled by user. The directory '{}' and its contents were not deleted. What would you like to do instead?",
path
)));
}
if resolved.is_inside_workspace {
self.fs_tool.delete_directory(path)?;
} else {
self.fs_tool
.delete_directory_with_outside_access(&resolved.canonical_str)?;
}
Ok(format!("Successfully deleted directory '{}'", path))
}
ToolName::MoveFile => {
let source = input["source"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'source' parameter".to_string())
})?;
let destination = input["destination"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'destination' parameter".to_string())
})?;
let src_resolved = self.resolve_existing(source)?;
let dst_resolved = self.resolve_for_write(destination)?;
if !src_resolved.is_inside_workspace {
self.check_write_access(
source,
&src_resolved.canonical_str,
&src_resolved.canonical,
)?;
}
if !dst_resolved.is_inside_workspace {
self.check_write_access(
destination,
&dst_resolved.canonical_str,
&dst_resolved.canonical,
)?;
}
move_between(
source,
destination,
&src_resolved,
&dst_resolved,
&self.fs_tool,
)?;
Ok(format!(
"Successfully moved '{}' to '{}'",
source, destination
))
}
ToolName::CopyFile => {
let source = input["source"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'source' parameter".to_string())
})?;
let destination = input["destination"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'destination' parameter".to_string())
})?;
let src_resolved = self.resolve_existing(source)?;
let dst_resolved = self.resolve_for_write(destination)?;
if !src_resolved.is_inside_workspace {
self.check_read_access(
source,
&src_resolved.canonical,
&src_resolved.canonical_str,
src_resolved.is_inside_workspace,
)?;
}
if !dst_resolved.is_inside_workspace {
self.check_write_access(
destination,
&dst_resolved.canonical_str,
&dst_resolved.canonical,
)?;
}
copy_between(
source,
destination,
&src_resolved,
&dst_resolved,
&self.fs_tool,
)?;
Ok(format!(
"Successfully copied '{}' to '{}'",
source, destination
))
}
ToolName::ExecuteBash => {
let command = input["command"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'command' parameter".to_string())
})?;
let wants_escalation = input["sandbox_permissions"]
.as_str()
.map(|s| {
s.eq_ignore_ascii_case("require_escalated")
|| s.eq_ignore_ascii_case("escalate")
})
.or_else(|| input["sandbox_permissions"].as_bool())
.or_else(|| input["require_escalated"].as_bool())
.unwrap_or(false);
let result = if wants_escalation {
let escalation = crate::tools::bash::EscalationRequest {
justification: input["justification"].as_str().map(|s| s.to_string()),
};
self.bash_executor
.execute_with_escalation(command, Some(escalation))?
} else {
self.bash_executor.execute(command)?
};
Ok(result)
}
ToolName::UpdatePlan => {
let update = plan::parse_plan_update(input)?;
return Ok(ToolExecutionResult::TextWithDisplay {
text: plan::model_summary(&update),
display: plan::render_plan(&update),
});
}
ToolName::ViewImage => {
let path = input["path"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'path' parameter".to_string())
})?;
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(SofosError::ToolExecution(
"'path' cannot be empty. Pass a local image file path or an http(s):// URL."
.to_string(),
));
}
if trimmed.starts_with("data:") {
return Err(SofosError::ToolExecution(
"view_image does not accept `data:` URLs. Save the image to a file \
(workspace-relative, absolute, or ~/) or expose it over http(s):// \
and call view_image with that path."
.to_string(),
));
}
let source = if is_http_url(trimmed) {
self.image_loader.prepare_web_image(trimmed)?
} else {
let candidate = resolve_view_image_candidate(self.fs_tool.workspace(), trimmed);
if std::fs::metadata(&candidate).is_ok_and(|m| m.is_dir()) {
return Err(SofosError::ToolExecution(format!(
"'{}' is a directory; view_image only opens files. \
Call list_directory on this path to find image files, \
then call view_image on each file you want to see.",
path
)));
}
self.image_loader.load_local_image(trimmed)?
};
let image = ImageData::from(source);
let display_text = match &image {
ImageData::Url { url } => format!("Loaded image from {}", url),
ImageData::Base64 { mime_type, data } => {
format!(
"Loaded image '{}' ({}, ~{} KB)",
path,
mime_type,
base64_approx_decoded_kb(data.len())
)
}
};
return Ok(ToolExecutionResult::Structured(McpToolResult {
text: display_text,
images: vec![image],
}));
}
ToolName::WebFetch => {
use futures::StreamExt;
let url = input["url"].as_str().ok_or_else(|| {
SofosError::ToolExecution("Missing 'url' parameter".to_string())
})?;
if !is_http_url(url) {
return Err(SofosError::ToolExecution(
"URL must start with http:// or https://".to_string(),
));
}
let parsed_url = reqwest::Url::parse(url).map_err(|_| {
SofosError::ToolExecution("web_fetch could not parse this URL".to_string())
})?;
let host = parsed_url.host_str().ok_or_else(|| {
SofosError::ToolExecution(
"web_fetch could not determine the host of this URL".to_string(),
)
})?;
permissions::check_web_fetch_session_access(
self.fs_tool.workspace(),
host,
self.interactive,
&self.web_fetch_session_allowed,
&self.web_fetch_session_denied,
)?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| SofosError::ToolExecution(format!("HTTP client error: {}", e)))?;
let response = self.web_fetch_follow_redirects(&client, parsed_url).await?;
let status = response.status();
if !status.is_success() {
return Err(SofosError::ToolExecution(format!(
"HTTP {} for {}",
status, url
)));
}
if let Some(announced) = response.content_length() {
if announced > MAX_WEB_FETCH_BODY_BYTES as u64 {
return Err(SofosError::ToolExecution(format!(
"Response from {} announces {} bytes, which exceeds the {} MB web_fetch cap; aborted before downloading.",
url,
announced,
MAX_WEB_FETCH_BODY_BYTES / (1024 * 1024)
)));
}
}
let mut stream = response.bytes_stream();
let mut raw: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
SofosError::ToolExecution(format!("Read body failed: {}", e))
})?;
if raw.len().saturating_add(chunk.len()) > MAX_WEB_FETCH_BODY_BYTES {
return Err(SofosError::ToolExecution(format!(
"Response from {} exceeded the {} MB web_fetch cap mid-stream; aborted.",
url,
MAX_WEB_FETCH_BODY_BYTES / (1024 * 1024)
)));
}
raw.extend_from_slice(&chunk);
}
let body = String::from_utf8_lossy(&raw).into_owned();
let text =
crate::tools::utils::html_to_text_capped(&body, WEB_FETCH_TEXT_BUDGET_BYTES);
let max_bytes = 64_000;
let truncated = if text.len() > max_bytes {
let end = crate::api::utils::truncate_at_char_boundary(&text, max_bytes);
format!(
"{}\n\n[TRUNCATED: showing first ~{} chars of {}]",
&text[..end],
max_bytes,
text.len()
)
} else {
text
};
Ok(format!("Content from {}:\n\n{}", url, truncated))
}
ToolName::WebSearch => Err(SofosError::ToolExecution(
"web_search is handled server-side by the API and should not be executed locally"
.to_string(),
)),
};
Ok(ToolExecutionResult::Text(text_result?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_fetch_redirect_classifies_only_real_redirects() {
use reqwest::StatusCode;
for status in [
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
] {
assert!(is_web_fetch_redirect(status), "{status} is a redirect");
}
for status in [
StatusCode::OK,
StatusCode::MULTIPLE_CHOICES,
StatusCode::NOT_MODIFIED,
StatusCode::NOT_FOUND,
] {
assert!(!is_web_fetch_redirect(status), "{status} is not a redirect");
}
}
#[test]
fn web_fetch_redirect_target_resolves_http_and_refuses_other_schemes() {
let current = reqwest::Url::parse("https://example.com/a/b").unwrap();
let cross = web_fetch_redirect_target(¤t, "https://other.example.org/x").unwrap();
assert_eq!(
cross.host_str(),
Some("other.example.org"),
"an absolute cross-host http(s) target resolves so the gate sees the new host"
);
let relative = web_fetch_redirect_target(¤t, "/landing").unwrap();
assert_eq!(relative.host_str(), Some("example.com"));
assert_eq!(
relative.path(),
"/landing",
"a relative target resolves against the current URL"
);
for target in [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,x",
"ftp://host/x",
] {
assert!(
web_fetch_redirect_target(¤t, target).is_err(),
"a non-http(s) redirect target must be refused: {target}"
);
}
}
#[test]
fn redirect_denied_names_both_hosts_and_keeps_the_reason() {
let denial = SofosError::ToolExecution(
"web_fetch to 'evil.example.com' was declined earlier this session".to_string(),
);
let message = format!(
"{}",
redirect_denied("good.example.com", "evil.example.com", denial)
);
assert!(
message.contains("good.example.com"),
"names the host the model requested: {message}"
);
assert!(
message.contains("redirected to 'evil.example.com'"),
"names the redirect target: {message}"
);
assert!(
message.contains("was declined earlier this session"),
"keeps the specific reason from the gate: {message}"
);
assert_eq!(
message.matches("Tool execution error:").count(),
1,
"the error prefix must not be doubled: {message}"
);
}
}