use super::*;
use crate::error::SofosError;
use crate::mcp::manager::{ImageData, ToolResult as McpToolResult};
use crate::tools::executor::{cap_mcp_images, cap_mcp_response};
use crate::tools::utils::{MAX_MCP_IMAGE_BYTES, MAX_MCP_IMAGE_COUNT};
use serde_json::json;
use tempfile::tempdir;
fn toml_path(p: &std::path::Path) -> String {
p.display().to_string().replace('\\', "\\\\")
}
fn sh_path(p: &std::path::Path) -> String {
p.display().to_string().replace('\\', "/")
}
fn fake_image(mime: &str, base64_len: usize) -> ImageData {
ImageData::Base64 {
mime_type: mime.to_string(),
data: "x".repeat(base64_len),
}
}
fn image_size(image: &ImageData) -> usize {
match image {
ImageData::Base64 { data, .. } => data.len(),
ImageData::Url { .. } => 0,
}
}
fn strip_ansi(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(char) = chars.next() {
if char == '\x1b' {
for inner in chars.by_ref() {
if inner.is_ascii_alphabetic() {
break;
}
}
} else {
output.push(char);
}
}
output
}
#[tokio::test]
async fn update_plan_dispatcher_returns_compact_model_result_and_styled_display() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"update_plan",
&json!({
"explanation": "Starting implementation",
"plan": [
{"step": "Inspect source", "status": "completed"},
{"step": "Add tool", "status": "in_progress"},
{"step": "Run checks", "status": "pending"}
]
}),
)
.await
.unwrap();
assert_eq!(
result.text(),
"Plan updated: 3 steps, 1 completed, 1 in progress, 1 pending. Current step: Add tool."
);
assert!(
!result.text().contains('\x1b'),
"the summary sent to the model must be free of ANSI escapes"
);
let display = strip_ansi(result.display_text());
assert!(display.contains("Plan updated"));
assert!(display.contains("Add tool"));
assert!(display.contains("1 completed · 1 in progress · 1 pending"));
}
#[test]
fn cap_mcp_images_drops_by_count() {
let images: Vec<ImageData> = (0..(MAX_MCP_IMAGE_COUNT + 5))
.map(|_| fake_image("image/png", 16))
.collect();
let mut result = McpToolResult {
text: String::new(),
images,
};
let dropped = cap_mcp_images(&mut result);
assert_eq!(dropped, 5, "should drop exactly the excess");
assert_eq!(result.images.len(), MAX_MCP_IMAGE_COUNT);
}
#[test]
fn cap_mcp_images_drops_by_total_bytes() {
let half = MAX_MCP_IMAGE_BYTES / 2 + 1;
let mut result = McpToolResult {
text: String::new(),
images: vec![
fake_image("image/png", half),
fake_image("image/png", half),
fake_image("image/png", half),
],
};
let dropped = cap_mcp_images(&mut result);
assert_eq!(
dropped, 2,
"second and third images exceed the total-byte cap"
);
assert_eq!(result.images.len(), 1);
}
#[test]
fn cap_mcp_images_preserves_all_when_under_caps() {
let mut result = McpToolResult {
text: String::new(),
images: vec![fake_image("image/png", 32); 3],
};
let dropped = cap_mcp_images(&mut result);
assert_eq!(dropped, 0);
assert_eq!(result.images.len(), 3);
}
#[test]
fn cap_mcp_response_drop_note_survives_text_truncation() {
let huge_text = "x".repeat(5 * 1024 * 1024); let mut result = McpToolResult {
text: huge_text,
images: (0..(MAX_MCP_IMAGE_COUNT + 3))
.map(|_| fake_image("image/png", 16))
.collect(),
};
cap_mcp_response(&mut result);
assert!(
result.text.contains("[TRUNCATED: MCP response has"),
"expected truncation suffix in text"
);
assert!(
result.text.contains("image attachment(s) dropped"),
"drop note must outlive text truncation, got: {}",
&result.text[result.text.len().saturating_sub(300)..]
);
assert_eq!(result.images.len(), MAX_MCP_IMAGE_COUNT);
}
#[test]
fn cap_mcp_response_no_drop_note_when_no_images_dropped() {
let mut result = McpToolResult {
text: "small response".to_string(),
images: vec![fake_image("image/png", 32); 3],
};
cap_mcp_response(&mut result);
assert_eq!(result.text, "small response");
assert_eq!(result.images.len(), 3);
assert!(!result.text.contains("dropped"));
}
#[test]
fn cap_mcp_images_skips_oversized_middle_image_greedily() {
let small = 1024; let oversized = MAX_MCP_IMAGE_BYTES + 1;
let mut result = McpToolResult {
text: String::new(),
images: vec![
fake_image("image/png", small),
fake_image("image/png", oversized),
fake_image("image/png", small),
],
};
let dropped = cap_mcp_images(&mut result);
assert_eq!(dropped, 1, "only the middle oversized image is dropped");
assert_eq!(result.images.len(), 2);
assert_eq!(image_size(&result.images[0]), small);
assert_eq!(image_size(&result.images[1]), small);
}
#[tokio::test]
async fn test_read_file_blocks_relative_escape() {
let temp_root = tempdir().unwrap();
let workspace = temp_root.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let sibling = temp_root.path().join("sibling");
std::fs::create_dir(&sibling).unwrap();
let escape_target = sibling.join("secret.txt");
std::fs::write(&escape_target, "secret").unwrap();
let config_dir = workspace.join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor = ToolExecutor::new(workspace, None, None, false, false).unwrap();
let result = executor
.execute("read_file", &json!({"path": "../sibling/secret.txt"}))
.await;
assert!(result.is_err());
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(msg.contains("outside workspace"), "got: {msg}");
} else {
panic!("Expected ToolExecution error about workspace escape, got: {result:?}");
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_resolve_for_write_canonicalizes_through_missing_ancestors() {
use std::os::unix::fs::symlink;
let workspace = tempdir().unwrap();
let real_target = tempdir().unwrap();
let real_target_canonical = std::fs::canonicalize(real_target.path()).unwrap();
let alias = workspace.path().join("alias");
symlink(&real_target_canonical, &alias).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let resolved = executor
.resolve_for_write("alias/missing/dir/file.txt")
.expect("resolve_for_write should succeed when an ancestor exists");
let expected = real_target_canonical
.join("missing")
.join("dir")
.join("file.txt");
assert_eq!(
resolved.canonical, expected,
"canonical path must route through the resolved symlink target",
);
assert_eq!(resolved.canonical_str, expected.to_string_lossy());
let workspace_canonical = std::fs::canonicalize(workspace.path()).unwrap();
assert_eq!(
resolved.is_inside_workspace,
real_target_canonical.starts_with(&workspace_canonical),
"is_inside_workspace must be computed against the canonical path",
);
}
#[tokio::test]
async fn test_read_file_allows_explicit_outside_path_with_glob() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_dir = outside.path().join("data");
std::fs::create_dir_all(&outside_dir).unwrap();
let outside_file = outside_dir.join("file.txt");
std::fs::write(&outside_file, "outside content").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/data/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"read_file",
&json!({"path": outside_file.to_string_lossy()}),
)
.await;
assert!(
result.is_ok(),
"Should allow file matching glob pattern: {:?}",
result
);
}
#[tokio::test]
async fn test_edit_file_replaces_string() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("test.txt"), "hello world").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({"path": "test.txt", "old_string": "world", "new_string": "rust"}),
)
.await;
assert!(result.is_ok());
let content = std::fs::read_to_string(workspace.path().join("test.txt")).unwrap();
assert_eq!(content, "hello rust");
}
#[tokio::test]
async fn test_edit_file_preserves_content_past_truncation_cap() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let head = "TARGET_MARKER\n".to_string();
let middle = "x".repeat(200_000);
let tail = "\nEND_OF_FILE_SENTINEL\n".to_string();
let original = format!("{head}{middle}{tail}");
std::fs::write(workspace.path().join("big.txt"), &original).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({
"path": "big.txt",
"old_string": "TARGET_MARKER",
"new_string": "REPLACED_MARKER",
}),
)
.await;
assert!(result.is_ok(), "edit_file failed: {:?}", result);
let on_disk = std::fs::read_to_string(workspace.path().join("big.txt")).unwrap();
assert!(
on_disk.starts_with("REPLACED_MARKER\n"),
"head must contain the replacement"
);
assert!(
on_disk.ends_with("END_OF_FILE_SENTINEL\n"),
"tail must be preserved — file was truncated: length={}",
on_disk.len()
);
assert!(
!on_disk.contains("[TRUNCATED:"),
"truncation suffix must never land on disk"
);
assert_eq!(
on_disk.len(),
original.len() + "REPLACED_MARKER".len() - "TARGET_MARKER".len(),
"file length should change only by the replacement delta"
);
}
#[tokio::test]
async fn test_edit_file_not_found_string() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("test.txt"), "hello world").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({"path": "test.txt", "old_string": "missing", "new_string": "x"}),
)
.await;
assert!(result.is_err());
let content = std::fs::read_to_string(workspace.path().join("test.txt")).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_edit_file_replace_all() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("test.txt"), "aaa bbb aaa").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({"path": "test.txt", "old_string": "aaa", "new_string": "ccc", "replace_all": true}),
)
.await;
assert!(result.is_ok());
let content = std::fs::read_to_string(workspace.path().join("test.txt")).unwrap();
assert_eq!(content, "ccc bbb ccc");
}
#[tokio::test]
async fn test_edit_file_returns_compact_summary_to_model_and_diff_to_display() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("greet.txt"), "hello world").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({"path": "greet.txt", "old_string": "world", "new_string": "rust"}),
)
.await
.unwrap();
assert_eq!(
result.text(),
"Success. Updated the following files:\nM greet.txt",
"model should receive only the compact summary"
);
assert!(
result
.display_text()
.contains("Successfully edited 'greet.txt'"),
"display should keep the per-tool success heading"
);
assert!(
result.display_text().contains("Changes:"),
"display should keep the diff preamble"
);
assert!(
!result.text().contains('\x1b'),
"the summary sent to the model must be free of ANSI escapes"
);
}
#[tokio::test]
async fn test_write_file_overwrite_returns_compact_summary_to_model() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("notes.txt"), "old body").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"write_file",
&json!({"path": "notes.txt", "content": "new body"}),
)
.await
.unwrap();
assert_eq!(
result.text(),
"Success. Updated the following files:\nM notes.txt"
);
assert!(
result
.display_text()
.contains("Successfully wrote to file 'notes.txt'")
);
}
#[tokio::test]
async fn test_glob_files_finds_matches() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let src = workspace.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("main.rs"), "").unwrap();
std::fs::write(src.join("lib.rs"), "").unwrap();
std::fs::write(workspace.path().join("README.md"), "").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "**/*.rs"}))
.await;
assert!(result.is_ok());
let text = result.unwrap().text().to_string();
assert!(text.contains("main.rs"));
assert!(text.contains("lib.rs"));
assert!(!text.contains("README.md"));
}
#[tokio::test]
async fn glob_files_star_does_not_cross_path_separator() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("top.rs"), "").unwrap();
let nested = workspace.path().join("src/inner");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("deep.rs"), "").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "*.rs"}))
.await
.unwrap();
let text = result.text().to_string();
assert!(
text.contains("top.rs"),
"top-level top.rs must still match `*.rs`: {text}"
);
assert!(
!text.contains("deep.rs"),
"`*.rs` must NOT walk into subdirectories: {text}"
);
let recursive = executor
.execute("glob_files", &json!({"pattern": "**/*.rs"}))
.await
.unwrap();
let recursive_text = recursive.text().to_string();
assert!(recursive_text.contains("top.rs"));
assert!(recursive_text.contains("deep.rs"));
}
#[tokio::test]
async fn test_glob_files_skips_default_excludes() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let src = workspace.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("main.rs"), "").unwrap();
let generated = workspace.path().join("target/debug/build/foo");
std::fs::create_dir_all(&generated).unwrap();
std::fs::write(generated.join("generated.rs"), "").unwrap();
let node_dep = workspace.path().join("node_modules/pkg");
std::fs::create_dir_all(&node_dep).unwrap();
std::fs::write(node_dep.join("index.rs"), "").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "**/*.rs"}))
.await;
assert!(result.is_ok());
let text = result.unwrap().text().to_string();
assert!(
text.contains("main.rs"),
"expected src/main.rs; got: {text}"
);
assert!(
!text.contains("generated.rs"),
"target/ descent must be blocked by default; got: {text}"
);
assert!(
!text.contains("node_modules"),
"node_modules/ descent must be blocked by default; got: {text}"
);
let result = executor
.execute(
"glob_files",
&json!({"pattern": "**/*.rs", "include_ignored": true}),
)
.await;
assert!(result.is_ok());
let text = result.unwrap().text().to_string();
assert!(
text.contains("generated.rs"),
"include_ignored=true must surface target/ contents; got: {text}"
);
}
#[tokio::test]
async fn test_glob_files_gates_external_path_through_permissions() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let outside = tempdir().unwrap();
std::fs::write(outside.path().join("SECRET_MARKER"), "leak").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "**/*", "path": ".."}))
.await;
match result {
Err(SofosError::ToolExecution(msg)) => assert!(
msg.contains("outside workspace"),
"expected 'outside workspace' hint; got: {msg}"
),
other => panic!("expected ToolExecution error for base='..'; got: {other:?}"),
}
let outside_abs = outside.path().to_string_lossy().to_string();
let result = executor
.execute(
"glob_files",
&json!({"pattern": "**/*", "path": outside_abs}),
)
.await;
match result {
Err(SofosError::ToolExecution(msg)) => assert!(
msg.contains("outside workspace"),
"expected 'outside workspace' hint; got: {msg}"
),
other => panic!("expected ToolExecution error for absolute path; got: {other:?}"),
}
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"glob_files",
&json!({
"pattern": "**/SECRET_MARKER",
"path": outside.path().to_string_lossy(),
}),
)
.await;
let text = result
.expect("glob_files with Read grant must succeed")
.text()
.to_string();
assert!(
text.contains("SECRET_MARKER"),
"expected grant to surface outside files; got: {text}"
);
}
#[tokio::test]
async fn test_glob_files_no_matches() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "**/*.xyz"}))
.await;
assert!(result.is_ok());
let text = result.unwrap().text().to_string();
assert!(text.contains("No files matching"));
}
#[tokio::test]
async fn test_write_file_to_external_path_blocked_without_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("file.txt");
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"write_file",
&json!({"path": outside_file.to_string_lossy(), "content": "test"}),
)
.await;
assert!(
result.is_err(),
"Write should be blocked without Write grant"
);
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(msg.contains("outside workspace"));
}
}
#[tokio::test]
async fn test_write_file_to_external_path_allowed_with_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("file.txt");
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"write_file",
&json!({"path": outside_file.to_string_lossy(), "content": "hello external"}),
)
.await;
assert!(
result.is_ok(),
"Write should succeed with Write grant: {:?}",
result
);
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "hello external");
}
#[tokio::test]
async fn test_edit_file_external_path_allowed_with_read_and_write_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("editable.txt");
std::fs::write(&outside_file, "foo bar baz").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({dir}/**)\", \"Write({dir}/**)\"]\ndeny = []\nask = []\n",
dir = toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({
"path": outside_file.to_string_lossy(),
"old_string": "bar",
"new_string": "qux"
}),
)
.await;
assert!(
result.is_ok(),
"Edit should succeed with Read + Write grant: {:?}",
result
);
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "foo qux baz");
}
#[tokio::test]
async fn test_edit_file_external_write_only_grant_denied() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("editable.txt");
std::fs::write(&outside_file, "foo bar baz").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({
"path": outside_file.to_string_lossy(),
"old_string": "bar",
"new_string": "qux",
}),
)
.await;
assert!(
matches!(result, Err(SofosError::ToolExecution(_))),
"Write-only grant should no longer suffice: {:?}",
result
);
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(
content, "foo bar baz",
"file must not be modified when denied"
);
}
#[tokio::test]
async fn test_read_grant_does_not_allow_write() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("readonly.txt");
std::fs::write(&outside_file, "original").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let read_result = executor
.execute(
"read_file",
&json!({"path": outside_file.to_string_lossy()}),
)
.await;
assert!(read_result.is_ok(), "Read should work with Read grant");
let edit_result = executor
.execute(
"edit_file",
&json!({
"path": outside_file.to_string_lossy(),
"old_string": "original",
"new_string": "modified"
}),
)
.await;
assert!(
edit_result.is_err(),
"Edit should be blocked — Read grant doesn't imply Write"
);
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "original");
}
#[tokio::test]
async fn test_list_directory_external_with_read_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_dir = outside.path().join("listing");
std::fs::create_dir_all(&outside_dir).unwrap();
std::fs::write(outside_dir.join("a.txt"), "").unwrap();
std::fs::write(outside_dir.join("b.txt"), "").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"list_directory",
&json!({"path": outside_dir.to_string_lossy()}),
)
.await;
assert!(
result.is_ok(),
"list_directory should work with Read grant: {:?}",
result
);
let text = result.unwrap().text().to_string();
assert!(text.contains("a.txt"));
assert!(text.contains("b.txt"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_symlink_does_not_bypass_write_permission() {
use std::os::unix::fs::symlink;
let workspace = tempdir().unwrap();
let allowed_dir = tempdir().unwrap();
let secret_dir = tempdir().unwrap();
let secret_file = secret_dir.path().join("secret.txt");
std::fs::write(&secret_file, "secret data").unwrap();
let link_path = allowed_dir.path().join("link.txt");
symlink(&secret_file, &link_path).unwrap();
let canonical_allowed = std::fs::canonicalize(allowed_dir.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_allowed)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({
"path": link_path.to_string_lossy(),
"old_string": "secret",
"new_string": "hacked"
}),
)
.await;
assert!(
result.is_err(),
"Symlink should not bypass Write permission scope"
);
let content = std::fs::read_to_string(&secret_file).unwrap();
assert_eq!(content, "secret data");
}
#[tokio::test]
async fn test_bash_external_path_blocked_without_grant() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("execute_bash", &json!({"command": "cat /etc/hosts"}))
.await;
assert!(result.is_err(), "Bash with external path should be blocked");
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("outside workspace") || msg.contains("Bash access denied"),
"Error should mention external path: {}",
msg
);
}
}
#[tokio::test]
async fn test_bash_external_path_allowed_with_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("readable.txt");
std::fs::write(&outside_file, "bash content").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Bash({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"execute_bash",
&json!({"command": format!("cat {}", sh_path(&outside_file))}),
)
.await;
assert!(
result.is_ok(),
"Bash with granted external path should work: {:?}",
result
);
let text = result.unwrap().text().to_string();
assert!(text.contains("bash content"), "got text: {text:?}");
}
#[tokio::test]
async fn test_edit_file_external_blocked_without_any_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("nowrite.txt");
std::fs::write(&outside_file, "protected").unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"edit_file",
&json!({
"path": outside_file.to_string_lossy(),
"old_string": "protected",
"new_string": "hacked"
}),
)
.await;
assert!(result.is_err(), "Edit should be blocked without any grant");
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "protected");
}
#[tokio::test]
async fn test_bash_grant_does_not_allow_read_or_write() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("bashonly.txt");
std::fs::write(&outside_file, "bash data").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Bash({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let read_result = executor
.execute(
"read_file",
&json!({"path": outside_file.to_string_lossy()}),
)
.await;
assert!(
read_result.is_err(),
"Read should be blocked — Bash grant doesn't imply Read"
);
let write_result = executor
.execute(
"write_file",
&json!({"path": outside_file.to_string_lossy(), "content": "overwrite"}),
)
.await;
assert!(
write_result.is_err(),
"Write should be blocked — Bash grant doesn't imply Write"
);
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "bash data");
}
#[tokio::test]
async fn test_write_deny_overrides_allow() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("denied.txt");
std::fs::write(&outside_file, "original").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
let canonical_file = std::fs::canonicalize(&outside_file).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = [\"Write({})\"]\nask = []\n",
toml_path(&canonical_outside),
toml_path(&canonical_file)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"write_file",
&json!({"path": outside_file.to_string_lossy(), "content": "new content"}),
)
.await;
assert!(result.is_err(), "Write should be blocked by deny rule");
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("denied") || msg.contains("Denied"),
"Error should mention deny: {}",
msg
);
}
let content = std::fs::read_to_string(&outside_file).unwrap();
assert_eq!(content, "original");
}
#[tokio::test]
async fn test_read_external_absolute_path_blocked_without_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_file = outside.path().join("noaccess.txt");
std::fs::write(&outside_file, "private").unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"read_file",
&json!({"path": outside_file.to_string_lossy()}),
)
.await;
assert!(
result.is_err(),
"Read external should be blocked without grant"
);
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("outside workspace"),
"Error should contain config hint: {}",
msg
);
assert!(
msg.contains("Read("),
"Error should hint at Read scope: {}",
msg
);
}
}
#[tokio::test]
async fn test_write_new_file_to_external_path() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let new_file = outside.path().join("brand_new.txt");
assert!(!new_file.exists());
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"write_file",
&json!({"path": new_file.to_string_lossy(), "content": "created externally"}),
)
.await;
assert!(
result.is_ok(),
"Writing new file to granted external path should work: {:?}",
result
);
assert!(new_file.exists());
let content = std::fs::read_to_string(&new_file).unwrap();
assert_eq!(content, "created externally");
}
#[tokio::test]
async fn test_bash_partial_path_grant_blocks_ungranated_path() {
let workspace = tempdir().unwrap();
let allowed = tempdir().unwrap();
let denied = tempdir().unwrap();
let allowed_file = allowed.path().join("ok.txt");
std::fs::write(&allowed_file, "allowed").unwrap();
let denied_file = denied.path().join("nope.txt");
std::fs::write(&denied_file, "denied").unwrap();
let canonical_allowed = std::fs::canonicalize(allowed.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Bash({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_allowed)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"execute_bash",
&json!({
"command": format!(
"cat {} {}",
allowed_file.to_string_lossy(),
denied_file.to_string_lossy()
)
}),
)
.await;
assert!(
result.is_err(),
"Bash command should be blocked when any path is not granted"
);
}
#[tokio::test]
async fn test_bash_deny_overrides_allow() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let outside_sub = outside.path().join("secret");
std::fs::create_dir_all(&outside_sub).unwrap();
std::fs::write(outside_sub.join("file.txt"), "secret data").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let canonical_sub = std::fs::canonicalize(&outside_sub).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Bash({}/**)\"]\ndeny = [\"Bash({}/**)\"]\nask = []\n",
toml_path(&canonical_outside),
toml_path(&canonical_sub)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let secret_file = canonical_sub.join("file.txt");
let result = executor
.execute(
"execute_bash",
&json!({"command": format!("cat {}", secret_file.display())}),
)
.await;
assert!(
result.is_err(),
"Bash should be blocked by deny rule even with broader allow: {:?}",
result
);
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("denied") || msg.contains("Denied"),
"Error should mention deny: {}",
msg
);
}
}
#[tokio::test]
async fn test_create_directory_external_requires_write_grant() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let external_dir = outside.path().join("new_subdir");
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"create_directory",
&json!({"path": external_dir.to_string_lossy()}),
)
.await;
assert!(
matches!(result, Err(SofosError::ToolExecution(_))),
"external mkdir without grant must fail: {:?}",
result
);
assert!(!external_dir.exists(), "directory must not be created");
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"create_directory",
&json!({"path": external_dir.to_string_lossy()}),
)
.await;
assert!(
result.is_ok(),
"mkdir with Write grant must succeed: {:?}",
result
);
assert!(external_dir.is_dir(), "directory must be created");
}
#[tokio::test]
async fn test_copy_file_external_source_and_destination() {
let workspace = tempdir().unwrap();
let outside_src = tempdir().unwrap();
let outside_dst = tempdir().unwrap();
let source_file = outside_src.path().join("src.txt");
std::fs::write(&source_file, "payload").unwrap();
let dest_file = outside_dst.path().join("dst.txt");
let canonical_src = std::fs::canonicalize(outside_src.path()).unwrap();
let canonical_dst = std::fs::canonicalize(outside_dst.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({src}/**)\", \"Write({dst}/**)\"]\ndeny = []\nask = []\n",
src = toml_path(&canonical_src),
dst = toml_path(&canonical_dst),
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"copy_file",
&json!({
"source": source_file.to_string_lossy(),
"destination": dest_file.to_string_lossy(),
}),
)
.await;
assert!(
result.is_ok(),
"copy with Read+Write grants must succeed: {:?}",
result
);
assert_eq!(
std::fs::read_to_string(&dest_file).unwrap(),
"payload",
"destination must contain the copied payload"
);
assert!(source_file.exists(), "copy must leave the source in place");
}
#[tokio::test]
async fn test_move_file_external_requires_write_on_source() {
let workspace = tempdir().unwrap();
let outside = tempdir().unwrap();
let source_file = outside.path().join("movable.txt");
std::fs::write(&source_file, "to move").unwrap();
let canonical_outside = std::fs::canonicalize(outside.path()).unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Read({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let dest_in_workspace = workspace.path().join("moved.txt");
let result = executor
.execute(
"move_file",
&json!({
"source": source_file.to_string_lossy(),
"destination": "moved.txt",
}),
)
.await;
assert!(
matches!(result, Err(SofosError::ToolExecution(_))),
"move with Read-only source grant must fail: {:?}",
result
);
assert!(
source_file.exists(),
"source must remain after a failed move"
);
assert!(
!dest_in_workspace.exists(),
"destination must not be created"
);
std::fs::write(
config_dir.join("config.local.toml"),
format!(
"[permissions]\nallow = [\"Write({}/**)\"]\ndeny = []\nask = []\n",
toml_path(&canonical_outside)
),
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"move_file",
&json!({
"source": source_file.to_string_lossy(),
"destination": "moved.txt",
}),
)
.await;
assert!(
result.is_ok(),
"move with Write grant must succeed: {:?}",
result
);
assert!(!source_file.exists(), "source must be removed after move");
assert_eq!(
std::fs::read_to_string(&dest_in_workspace).unwrap(),
"to move"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_glob_files_symlink_not_followed_by_default() {
use std::os::unix::fs::symlink;
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("anchor.txt"), "").unwrap();
let hidden = workspace.path().join("real_hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("through_symlink.txt"), "").unwrap();
symlink(&hidden, workspace.path().join("alias")).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("glob_files", &json!({"pattern": "**/through_symlink.txt"}))
.await;
let text = result.unwrap().text().to_string();
assert!(
text.contains("real_hidden/through_symlink.txt"),
"the file via its real path must always be found; got: {text}"
);
assert!(
!text.contains("alias/through_symlink.txt"),
"default walk must not follow the symlink; got: {text}"
);
let result = executor
.execute(
"glob_files",
&json!({"pattern": "**/through_symlink.txt", "follow_symlinks": true}),
)
.await;
let text = result.unwrap().text().to_string();
assert!(
text.contains("alias/through_symlink.txt"),
"follow_symlinks=true must surface the aliased path; got: {text}"
);
}
fn tiny_png_bytes() -> Vec<u8> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
)
.expect("hardcoded fixture decodes")
}
fn write_png_file(path: &std::path::Path, width: u32, height: u32, rgba: [u8; 4]) {
use ::image::{DynamicImage, ImageBuffer, Rgba};
use std::io::Cursor;
let buffer = ImageBuffer::from_pixel(width, height, Rgba(rgba));
let mut cursor = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(buffer)
.write_to(&mut cursor, ::image::ImageFormat::Png)
.expect("encode png fixture");
std::fs::write(path, cursor.into_inner()).expect("write png fixture to disk");
}
#[tokio::test]
async fn view_image_loads_local_file_as_base64() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("pixel.png"), tiny_png_bytes()).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("view_image", &json!({"path": "pixel.png"}))
.await
.expect("view_image on local file should succeed");
assert_eq!(result.images().len(), 1, "expected exactly one image");
match &result.images()[0] {
ImageData::Base64 { mime_type, data } => {
assert_eq!(mime_type, "image/png");
assert!(!data.is_empty(), "base64 payload should be non-empty");
}
ImageData::Url { .. } => panic!("local file should produce Base64, not Url"),
}
assert!(
result.text().contains("pixel.png"),
"model-facing text should reference the loaded path; got: {}",
result.text()
);
}
#[tokio::test]
async fn view_image_passes_http_url_through() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute(
"view_image",
&json!({"path": "https://example.com/banner.png"}),
)
.await
.expect("view_image on URL should succeed");
assert_eq!(result.images().len(), 1);
match &result.images()[0] {
ImageData::Url { url } => {
assert_eq!(url, "https://example.com/banner.png");
}
ImageData::Base64 { .. } => panic!("URL input should produce Url variant"),
}
}
#[tokio::test]
async fn view_image_rejects_directory_with_chaining_hint() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::create_dir_all(workspace.path().join("assets")).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("view_image", &json!({"path": "assets"}))
.await;
let err = result.expect_err("view_image on a directory must error");
let msg = format!("{err}");
assert!(
msg.contains("list_directory"),
"error should steer the model to list_directory first; got: {msg}"
);
}
#[tokio::test]
async fn view_image_rejects_missing_file_clearly() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("view_image", &json!({"path": "does-not-exist.png"}))
.await;
let err = result.expect_err("missing image must error");
let msg = format!("{err}");
assert!(
msg.contains("does-not-exist.png"),
"error should name the missing path; got: {msg}"
);
}
#[tokio::test]
async fn view_image_rejects_non_image_extension() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
std::fs::write(workspace.path().join("notes.txt"), b"not an image").unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("view_image", &json!({"path": "notes.txt"}))
.await;
let err = result.expect_err("non-image file must be rejected");
let msg = format!("{err}");
assert!(
msg.to_lowercase().contains("format")
|| msg.to_lowercase().contains("unsupported")
|| msg.to_lowercase().contains("extension"),
"error should mention the unsupported format; got: {msg}"
);
}
#[tokio::test]
async fn view_image_rejects_empty_path() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor.execute("view_image", &json!({"path": ""})).await;
assert!(result.is_err(), "empty path must be rejected");
}
#[tokio::test]
async fn view_image_resizes_oversized_local_file_end_to_end() {
use ::image::GenericImageView;
use base64::Engine;
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
write_png_file(
&workspace.path().join("big.png"),
4096,
2048,
[10, 20, 30, 255],
);
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let result = executor
.execute("view_image", &json!({"path": "big.png"}))
.await
.expect("view_image on oversized file should succeed");
let base64_data = match &result.images()[0] {
ImageData::Base64 { data, .. } => data.clone(),
ImageData::Url { .. } => panic!("local file should produce Base64"),
};
let raw = base64::engine::general_purpose::STANDARD
.decode(&base64_data)
.expect("tool output should be valid base64");
let decoded = ::image::load_from_memory(&raw).expect("base64 should decode to a valid image");
assert_eq!(
decoded.dimensions(),
(2048, 1024),
"oversized local image must be downscaled before reaching the model"
);
}
#[tokio::test]
async fn view_image_rejects_file_exceeding_size_cap() {
let workspace = tempdir().unwrap();
let config_dir = workspace.path().join(".sofos");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.local.toml"),
"[permissions]\nallow = []\ndeny = []\nask = []\n",
)
.unwrap();
let oversized = vec![0u8; (crate::tools::image::MAX_IMAGE_SIZE_BYTES as usize) + 1];
std::fs::write(workspace.path().join("huge.png"), oversized).unwrap();
let executor =
ToolExecutor::new(workspace.path().to_path_buf(), None, None, false, false).unwrap();
let err = executor
.execute("view_image", &json!({"path": "huge.png"}))
.await
.expect_err("file above the size cap must be rejected");
let msg = format!("{err}");
assert!(
msg.to_lowercase().contains("too large"),
"error should mention the size cap; got: {msg}"
);
}