#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum ToolEffect {
ReadOnly,
RemoteAction,
LocalMutation,
Destructive,
}
impl ToolEffect {
#[inline]
pub fn is_mutating(self) -> bool {
!matches!(self, ToolEffect::ReadOnly)
}
}
pub mod agent;
pub mod ask_user;
pub mod bg_process;
pub mod bg_task_tools;
pub mod catalog;
pub use catalog::ToolCatalog;
pub mod tool_trait;
pub use tool_trait::{DynTool, Tool, ToolExecCtx, boxed};
pub mod file_tools;
pub mod fuzzy;
pub mod glob_tool;
pub mod grep;
pub mod memory;
pub mod recall;
pub mod shell;
pub mod skill_tools;
pub mod summary;
pub mod todo;
pub mod validate;
pub mod web_fetch;
pub mod web_search;
use anyhow::Result;
use koda_sandbox::fs::{FileSystem, LocalFileSystem};
use path_clean::PathClean;
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use crate::output_caps::OutputCaps;
use crate::providers::ToolDefinition;
pub type FileReadCache = Arc<std::sync::Mutex<HashMap<String, (u64, SystemTime, String)>>>;
pub type LastWriterCache = Arc<std::sync::Mutex<HashMap<PathBuf, (String, std::time::Instant)>>>;
pub type LastBashCache = Arc<std::sync::Mutex<Option<(String, std::time::Instant)>>>;
#[derive(Debug, Clone)]
pub struct ToolResult {
pub output: String,
pub success: bool,
pub full_output: Option<String>,
}
pub fn wrap_result(r: anyhow::Result<String>) -> ToolResult {
match r {
Ok(output) => ToolResult {
output,
success: true,
full_output: None,
},
Err(e) => ToolResult {
output: format!("Error: {e:#}"),
success: false,
full_output: None,
},
}
}
pub struct ToolRegistry {
project_root: PathBuf,
catalog: ToolCatalog,
read_cache: FileReadCache,
fs: Arc<dyn FileSystem + Send + Sync>,
last_writer: LastWriterCache,
last_bash: LastBashCache,
pub undo: std::sync::Mutex<crate::undo::UndoStack>,
pub skill_registry: crate::skills::SkillRegistry,
db: std::sync::RwLock<Option<std::sync::Arc<crate::db::Database>>>,
session_id: std::sync::RwLock<Option<String>>,
pub caps: OutputCaps,
pub bg_registry: bg_process::BgRegistry,
trust: crate::trust::TrustMode,
sandbox_policy: koda_sandbox::SandboxPolicy,
proxy_port: std::sync::RwLock<Option<u16>>,
socks5_port: std::sync::RwLock<Option<u16>>,
}
impl ToolRegistry {
pub fn new(project_root: PathBuf, max_context_tokens: usize) -> Self {
Self::with_trust(
project_root,
max_context_tokens,
crate::trust::TrustMode::Safe,
)
}
pub fn with_trust(
project_root: PathBuf,
max_context_tokens: usize,
trust: crate::trust::TrustMode,
) -> Self {
let catalog = ToolCatalog::new();
let skill_registry = crate::skills::SkillRegistry::discover(&project_root);
Self {
project_root,
catalog,
read_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
fs: Arc::new(LocalFileSystem::new()),
last_writer: Arc::new(std::sync::Mutex::new(HashMap::new())),
last_bash: Arc::new(std::sync::Mutex::new(None)),
undo: std::sync::Mutex::new(crate::undo::UndoStack::new()),
skill_registry,
db: std::sync::RwLock::new(None),
session_id: std::sync::RwLock::new(None),
caps: OutputCaps::for_context(max_context_tokens),
bg_registry: bg_process::BgRegistry::new(),
trust,
sandbox_policy: koda_sandbox::SandboxPolicy::strict_default(),
proxy_port: std::sync::RwLock::new(None),
socks5_port: std::sync::RwLock::new(None),
}
}
pub fn with_shared_cache(mut self, cache: FileReadCache) -> Self {
self.read_cache = cache;
self
}
pub fn with_sandbox_policy(mut self, policy: koda_sandbox::SandboxPolicy) -> Self {
self.sandbox_policy = policy;
self
}
pub fn sandbox_policy(&self) -> &koda_sandbox::SandboxPolicy {
&self.sandbox_policy
}
pub fn set_fs(&mut self, fs: Arc<dyn FileSystem + Send + Sync>) {
self.fs = fs;
}
pub fn file_read_cache(&self) -> FileReadCache {
Arc::clone(&self.read_cache)
}
pub fn last_writer_cache(&self) -> LastWriterCache {
Arc::clone(&self.last_writer)
}
pub fn last_bash_cache(&self) -> LastBashCache {
Arc::clone(&self.last_bash)
}
pub fn set_session(&self, db: std::sync::Arc<crate::db::Database>, session_id: String) {
if let Ok(mut guard) = self.db.write() {
*guard = Some(db);
}
if let Ok(mut guard) = self.session_id.write() {
*guard = Some(session_id);
}
}
pub fn set_mcp_manager(&self, manager: Arc<tokio::sync::RwLock<crate::mcp::McpManager>>) {
self.catalog.set_mcp_manager(manager);
}
pub fn mcp_manager(&self) -> Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>> {
self.catalog.mcp_manager()
}
pub fn set_proxy_port(&self, port: Option<u16>) {
if let Ok(mut guard) = self.proxy_port.write() {
*guard = port;
}
}
pub fn proxy_port(&self) -> Option<u16> {
self.proxy_port.read().ok().and_then(|guard| *guard)
}
pub fn set_socks5_port(&self, port: Option<u16>) {
if let Ok(mut guard) = self.socks5_port.write() {
*guard = port;
}
}
pub fn socks5_port(&self) -> Option<u16> {
self.socks5_port.read().ok().and_then(|guard| *guard)
}
pub fn catalog(&self) -> &ToolCatalog {
&self.catalog
}
pub fn classify_tool_with_mcp(&self, name: &str) -> ToolEffect {
self.catalog.classify_tool_with_mcp(name)
}
pub fn all_builtin_tool_names(&self) -> Vec<String> {
self.catalog.all_builtin_tool_names()
}
pub fn has_tool(&self, name: &str) -> bool {
self.catalog.has_tool(name)
}
pub fn list_skills(&self) -> Vec<(String, String, String)> {
self.skill_registry
.list()
.into_iter()
.map(|m| {
let source = match m.source {
crate::skills::SkillSource::BuiltIn => "built-in",
crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::Project => "project",
};
(m.name.clone(), m.description.clone(), source.to_string())
})
.collect()
}
pub fn search_skills(&self, query: &str) -> Vec<(String, String, String)> {
self.skill_registry
.search(query)
.into_iter()
.map(|m| {
let source = match m.source {
crate::skills::SkillSource::BuiltIn => "built-in",
crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::Project => "project",
};
(m.name.clone(), m.description.clone(), source.to_string())
})
.collect()
}
pub fn get_definitions(&self, allowed: &[String], denied: &[String]) -> Vec<ToolDefinition> {
self.catalog.get_definitions(allowed, denied)
}
pub async fn execute(
&self,
name: &str,
arguments: &str,
sink_for_streaming: Option<(&dyn crate::engine::EngineSink, &str)>,
caller_spawner: Option<u32>,
) -> ToolResult {
let raw = arguments.trim();
let raw = if raw.is_empty() { "{}" } else { raw };
let args: Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(e) => {
return ToolResult {
output: format!("Invalid JSON arguments: {e}"),
success: false,
full_output: None,
};
}
};
tracing::info!(
"Executing tool: {name} with args: [{} chars]",
arguments.len()
);
let undo_path = self
.catalog
.get_tool(name)
.and_then(|tool| tool.extract_undo_path(&args));
if let Some(file_path) = undo_path {
let resolved = self.project_root.join(&file_path);
if let Ok(mut undo) = self.undo.lock() {
undo.snapshot(&resolved);
}
}
if let Some(tool) = self.catalog.get_tool(name) {
let policy = self.sandbox_policy();
let proxy_port = self.proxy_port();
let socks5_port = self.socks5_port();
let db_arc = self.db.read().ok().and_then(|g| g.clone());
let sid_str = self.session_id.read().ok().and_then(|g| g.clone());
let session = match (db_arc.as_deref(), sid_str.as_deref()) {
(Some(db), Some(sid)) => Some((db, sid)),
_ => None,
};
let ctx = tool_trait::ToolExecCtx {
project_root: &self.project_root,
read_cache: &self.read_cache,
fs: &*self.fs,
caps: &self.caps,
sink: sink_for_streaming,
caller_spawner,
bg_registry: &self.bg_registry,
trust: &self.trust,
sandbox_policy: policy,
proxy_port,
socks5_port,
session,
skill_registry: &self.skill_registry,
};
let result = tool.execute(&ctx, &args).await;
if result.success {
if matches!(name, "Write" | "Edit")
&& let Some(path) =
crate::file_tracker::resolve_file_path_from_args(&args, &self.project_root)
&& let Ok(mut guard) = self.last_writer.lock()
{
guard.insert(path, (name.to_string(), std::time::Instant::now()));
}
if name == "Bash" {
let snippet = args["command"]
.as_str()
.unwrap_or("")
.chars()
.take(72)
.collect::<String>();
if !snippet.is_empty()
&& let Ok(mut guard) = self.last_bash.lock()
{
*guard = Some((snippet, std::time::Instant::now()));
}
}
}
return result;
}
if crate::mcp::is_mcp_tool_name(name) {
if let Some(mgr) = self.mcp_manager() {
let result = {
let mgr = mgr.read().await;
mgr.call_tool(name, args.clone()).await
};
return match result {
Ok(output) => ToolResult {
output,
success: true,
full_output: None,
},
Err(e) => ToolResult {
output: format!("Error: {e:#}"),
success: false,
full_output: None,
},
};
}
return ToolResult {
output: format!(
"MCP tool '{name}' not available \u{2014} \
no MCP servers connected."
),
success: false,
full_output: None,
};
}
let warning = if name.contains('{') || name.len() > 64 {
format!(
"Unknown tool: {name}. \
This model appears to struggle with tool calling. \
Consider switching to a model with native function-call support."
)
} else {
format!("Unknown tool: {name}")
};
ToolResult {
output: format!("Error: {warning}"),
success: false,
full_output: None,
}
}
}
pub fn safe_resolve_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
let requested_path = Path::new(requested);
let resolved = if requested_path.is_absolute() {
requested_path.to_path_buf().clean()
} else {
project_root.join(requested_path).clean()
};
if !resolved.starts_with(project_root) && !is_allowed_write_root(&resolved) {
anyhow::bail!(
"Path {requested:?} is outside the project root ({project_root:?}) \
and not under a writable tempdir (/tmp, /var/tmp, $TMPDIR). \
Write, Edit, and Delete are restricted to the project directory \
and tempdirs to prevent accidental modification of files \
elsewhere. Tell the user: to write outside these locations, \
restart koda from a parent directory that contains both paths."
);
}
if crate::sandbox::is_fully_denied(&resolved) {
anyhow::bail!(
"Path {requested:?} is denied: this path contains koda's \
internal secrets and cannot be modified by tool calls."
);
}
Ok(resolved)
}
fn is_allowed_write_root(path: &Path) -> bool {
const TEMPDIR_PREFIXES: &[&str] = &["/tmp", "/private/tmp", "/var/tmp"];
if TEMPDIR_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
{
return true;
}
path.starts_with(std::env::temp_dir())
}
pub fn allowed_mutation_roots(project_root: &Path) -> Vec<PathBuf> {
let mut roots = vec![
project_root.to_path_buf(),
PathBuf::from("/tmp"),
PathBuf::from("/private/tmp"),
PathBuf::from("/var/tmp"),
std::env::temp_dir(),
];
roots.sort();
roots.dedup();
roots
}
pub(crate) fn resolve_path_unrestricted(project_root: &Path, requested: &str) -> PathBuf {
let path = Path::new(requested);
if path.is_absolute() {
path.to_path_buf().clean()
} else {
project_root.join(path).clean()
}
}
pub fn resolve_read_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
let resolved = resolve_path_unrestricted(project_root, requested);
if crate::sandbox::is_fully_denied(&resolved) {
anyhow::bail!(
"Access to {requested:?} is denied: this path contains koda's \
internal secrets and cannot be read by model tool calls."
);
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn root() -> PathBuf {
PathBuf::from("/home/user/project")
}
#[test]
fn proxy_port_defaults_to_none() {
let registry = ToolRegistry::new(root(), 100_000);
assert_eq!(registry.proxy_port(), None);
}
#[test]
fn proxy_port_round_trips_through_setter() {
let registry = ToolRegistry::new(root(), 100_000);
registry.set_proxy_port(Some(31415));
assert_eq!(registry.proxy_port(), Some(31415));
}
#[test]
fn socks5_port_defaults_to_none() {
let registry = ToolRegistry::new(root(), 100_000);
assert_eq!(registry.socks5_port(), None);
}
#[test]
fn socks5_port_round_trips_through_setter() {
let registry = ToolRegistry::new(root(), 100_000);
registry.set_socks5_port(Some(27182));
assert_eq!(registry.socks5_port(), Some(27182));
}
#[test]
fn socks5_and_http_ports_are_independent() {
let registry = ToolRegistry::new(root(), 100_000);
registry.set_proxy_port(Some(8080));
registry.set_socks5_port(Some(1080));
assert_eq!(registry.proxy_port(), Some(8080));
assert_eq!(registry.socks5_port(), Some(1080));
registry.set_proxy_port(None);
assert_eq!(registry.socks5_port(), Some(1080));
}
#[test]
fn test_relative_path_resolves_inside_root() {
let result = safe_resolve_path(&root(), "src/main.rs").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
}
#[test]
fn test_dot_path_resolves_to_root() {
let result = safe_resolve_path(&root(), ".").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project"));
}
#[test]
fn test_new_file_in_new_dir_resolves() {
let result = safe_resolve_path(&root(), "src/brand_new/feature.rs").unwrap();
assert_eq!(
result,
PathBuf::from("/home/user/project/src/brand_new/feature.rs")
);
}
#[test]
fn test_dotdot_traversal_blocked() {
let result = safe_resolve_path(&root(), "../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_dotdot_sneaky_traversal_blocked() {
let result = safe_resolve_path(&root(), "src/../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_absolute_path_inside_root_allowed() {
let result = safe_resolve_path(&root(), "/home/user/project/src/lib.rs").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project/src/lib.rs"));
}
#[test]
fn test_absolute_path_outside_root_blocked() {
let result = safe_resolve_path(&root(), "/etc/shadow");
assert!(result.is_err());
}
#[test]
fn test_outside_root_error_is_actionable_for_user() {
let err = safe_resolve_path(&root(), "../../etc/passwd").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("outside the project root"),
"error must say 'outside the project root'; got: {msg}"
);
assert!(
msg.contains("Tell the user"),
"error must direct model to surface this to the user; got: {msg}"
);
assert!(
!msg.contains("Bash"),
"error must not suggest Bash as a workaround; got: {msg}"
);
}
#[test]
fn test_empty_path_resolves_to_root() {
let result = safe_resolve_path(&root(), "").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project"));
}
#[test]
fn read_path_allows_project_file() {
let p = resolve_read_path(&root(), "src/lib.rs").unwrap();
assert_eq!(p, PathBuf::from("/home/user/project/src/lib.rs"));
}
#[test]
fn read_path_allows_outside_project() {
let p = resolve_read_path(&root(), "/etc/hosts").unwrap();
assert_eq!(p, PathBuf::from("/etc/hosts"));
}
#[test]
fn read_path_blocks_koda_db() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
let koda_db = format!("{home}/.config/koda/db/koda.db");
let err = resolve_read_path(&root(), &koda_db).unwrap_err();
assert!(
err.to_string().contains("denied"),
"expected 'denied' in error, got: {err}"
);
}
#[test]
fn write_path_allows_tmp() {
let p = safe_resolve_path(&root(), "/tmp/koda-scratch.txt").unwrap();
assert_eq!(p, PathBuf::from("/tmp/koda-scratch.txt"));
}
#[test]
fn write_path_allows_private_tmp_macos_realpath() {
let p = safe_resolve_path(&root(), "/private/tmp/koda-scratch.txt").unwrap();
assert_eq!(p, PathBuf::from("/private/tmp/koda-scratch.txt"));
}
#[test]
fn write_path_allows_var_tmp() {
let p = safe_resolve_path(&root(), "/var/tmp/koda-scratch.txt").unwrap();
assert_eq!(p, PathBuf::from("/var/tmp/koda-scratch.txt"));
}
#[test]
fn write_path_allows_per_user_tmpdir() {
let tmpdir = std::env::temp_dir();
let target = tmpdir.join("koda-scratch.txt");
let p = safe_resolve_path(&root(), target.to_str().unwrap()).unwrap();
assert_eq!(p, target.clean());
}
#[test]
fn write_path_blocks_etc_hosts() {
let err = safe_resolve_path(&root(), "/etc/hosts").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("outside the project root"),
"system paths must still be rejected; got: {msg}"
);
}
#[test]
fn write_path_blocks_ssh_authorized_keys() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
let target = format!("{home}/.ssh/authorized_keys");
assert!(
safe_resolve_path(&root(), &target).is_err(),
"~/.ssh writes must remain blocked"
);
}
#[test]
fn write_path_blocks_koda_db_even_via_tmp_traversal() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
let target = format!("/tmp/../{home}/.config/koda/db/koda.db");
assert!(
safe_resolve_path(&root(), &target).is_err(),
"traversal out of /tmp must not bypass the gate"
);
}
#[test]
fn write_path_traversal_inside_tmp_stays_in_tmp() {
let p = safe_resolve_path(&root(), "/tmp/foo/../bar").unwrap();
assert_eq!(p, PathBuf::from("/tmp/bar"));
}
async fn registry_with_session() -> (
ToolRegistry,
tempfile::TempDir,
std::sync::Arc<crate::db::Database>,
String,
) {
use crate::persistence::Persistence;
let dir = tempfile::TempDir::new().unwrap();
let db = std::sync::Arc::new(
crate::db::Database::open(&dir.path().join("test.db"))
.await
.unwrap(),
);
let sid = db.create_session("koda", dir.path()).await.unwrap();
let registry = ToolRegistry::new(dir.path().to_path_buf(), 100_000);
*registry.db.write().unwrap() = Some(db.clone());
*registry.session_id.write().unwrap() = Some(sid.clone());
(registry, dir, db, sid)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn todo_write_emits_todo_update_event_on_first_write() {
let (registry, _dir, _db, _sid) = registry_with_session().await;
let sink = crate::engine::sink::TestSink::new();
let result = registry
.execute(
"TodoWrite",
r#"{"todos":[{"content":"Add tests","status":"pending","priority":"high"}]}"#,
Some((&sink, "call-1")),
None,
)
.await;
assert!(result.success, "first write must succeed: {result:?}");
assert_eq!(sink.len(), 1, "first write must emit exactly one event");
match &sink.events()[0] {
crate::engine::EngineEvent::TodoUpdate { items, diff } => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].content, "Add tests");
assert_eq!(diff.added.len(), 1, "first write → everything in added");
assert!(diff.changed.is_empty());
assert!(diff.removed.is_empty());
}
other => panic!("expected TodoUpdate, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn todo_write_suppresses_event_on_unchanged_rewrite() {
let (registry, _dir, _db, _sid) = registry_with_session().await;
let payload = r#"{"todos":[{"content":"A","status":"pending","priority":"high"}]}"#;
let sink1 = crate::engine::sink::TestSink::new();
registry
.execute("TodoWrite", payload, Some((&sink1, "c1")), None)
.await;
assert_eq!(sink1.len(), 1);
let sink2 = crate::engine::sink::TestSink::new();
let result2 = registry
.execute("TodoWrite", payload, Some((&sink2, "c2")), None)
.await;
assert!(result2.success);
assert!(
result2.output.contains("unchanged"),
"model-facing message must still nudge: {}",
result2.output
);
assert_eq!(
sink2.len(),
0,
"unchanged rewrite must NOT emit a TodoUpdate event"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn todo_write_returns_model_message_even_without_sink() {
let (registry, _dir, _db, _sid) = registry_with_session().await;
let result = registry
.execute(
"TodoWrite",
r#"{"todos":[{"content":"X","status":"pending","priority":"low"}]}"#,
None,
None,
)
.await;
assert!(result.success);
assert!(result.output.contains("0/1 done"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn todo_write_rejects_two_in_progress_at_dispatch() {
let (registry, _dir, _db, _sid) = registry_with_session().await;
let sink = crate::engine::sink::TestSink::new();
let result = registry
.execute(
"TodoWrite",
r#"{"todos":[
{"content":"A","status":"in_progress","priority":"high"},
{"content":"B","status":"in_progress","priority":"medium"}
]}"#,
Some((&sink, "c1")),
None,
)
.await;
assert!(
!result.success,
"two in_progress must produce a failed ToolResult"
);
assert!(
result.output.contains("Only one task"),
"failure message must explain the rule: {}",
result.output
);
assert_eq!(sink.len(), 0, "failed validation must not emit an event");
}
}
pub fn describe_action(tool_name: &str, args: &serde_json::Value) -> String {
match tool_name {
"Bash" => {
let cmd = args
.get("command")
.or(args.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let bg = args
.get("background")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if bg {
format!("[bg] {cmd}")
} else {
cmd.to_string()
}
}
"Delete" => {
let path = args
.get("file_path")
.or(args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let recursive = args
.get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if recursive {
format!("Delete directory (recursive): {path}")
} else {
format!("Delete: {path}")
}
}
"Write" => {
let path = args
.get("path")
.or(args.get("file_path"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let overwrite = args
.get("overwrite")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if overwrite {
format!("Overwrite file: {path}")
} else {
format!("Create file: {path}")
}
}
"Edit" => {
let path = if let Some(payload) = args.get("payload") {
payload
.get("file_path")
.or(payload.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?")
} else {
args.get("file_path")
.or(args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?")
};
format!("Edit file: {path}")
}
"WebFetch" => {
let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("?");
format!("Fetch URL: {url}")
}
"WebSearch" => {
let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("?");
format!("Web search: {q}")
}
"TodoWrite" => {
let n = args
.get("todos")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
format!("Update todo list ({n} tasks)")
}
"MemoryWrite" => {
let fact = args.get("fact").and_then(|v| v.as_str()).unwrap_or("?");
let preview = if fact.len() > 60 {
format!("{}…", &fact[..57])
} else {
fact.to_string()
};
format!("Save to memory: {preview}")
}
_ => format!("Execute: {tool_name}"),
}
}
#[cfg(test)]
mod describe_action_tests {
use super::*;
use serde_json::json;
#[test]
fn test_describe_bash() {
let desc = describe_action("Bash", &json!({"command": "cargo build"}));
assert!(desc.contains("cargo build"));
}
#[test]
fn test_describe_delete() {
let desc = describe_action("Delete", &json!({"file_path": "old.rs"}));
assert!(desc.contains("old.rs"));
}
#[test]
fn test_describe_edit() {
let desc = describe_action("Edit", &json!({"payload": {"file_path": "src/main.rs"}}));
assert!(desc.contains("src/main.rs"));
}
#[test]
fn test_describe_write() {
let desc = describe_action("Write", &json!({"path": "new.rs"}));
assert!(desc.contains("Create file"));
assert!(desc.contains("new.rs"));
}
#[test]
fn test_describe_write_overwrite() {
let desc = describe_action("Write", &json!({"path": "x.rs", "overwrite": true}));
assert!(desc.contains("Overwrite"));
}
#[test]
fn test_get_definitions_deny_list() {
let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
let denied = vec![
"Write".to_string(),
"Edit".to_string(),
"Delete".to_string(),
];
let defs = registry.get_definitions(&[], &denied);
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert!(!names.contains(&"Write"));
assert!(!names.contains(&"Edit"));
assert!(!names.contains(&"Delete"));
assert!(names.contains(&"Read"));
assert!(names.contains(&"Grep"));
}
#[test]
fn test_get_definitions_allow_list_wins_over_deny() {
let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
let allowed = vec!["Read".to_string(), "Write".to_string()];
let denied = vec!["Write".to_string()];
let defs = registry.get_definitions(&allowed, &denied);
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"Read"));
assert!(names.contains(&"Write"));
}
#[test]
fn test_get_definitions_both_empty_returns_all() {
let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
let all = registry.get_definitions(&[], &[]);
assert!(all.len() > 10, "Should have many tools");
}
#[test]
fn registry_sandbox_policy_defaults_to_strict() {
let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
assert_eq!(
*registry.sandbox_policy(),
koda_sandbox::SandboxPolicy::strict_default(),
"PR-2 contract: ToolRegistry::new must seed strict_default() so \
pre-PR callers see unchanged behavior"
);
}
#[test]
fn with_sandbox_policy_overrides_the_default() {
let mut custom = koda_sandbox::SandboxPolicy::strict_default();
custom
.fs
.allow_write
.push(koda_sandbox::PathPattern::new("/pr2-marker"));
let registry =
ToolRegistry::new(PathBuf::from("/tmp"), 128_000).with_sandbox_policy(custom.clone());
assert_eq!(
*registry.sandbox_policy(),
custom,
"with_sandbox_policy must replace the field, not no-op"
);
assert_ne!(
*registry.sandbox_policy(),
koda_sandbox::SandboxPolicy::strict_default(),
"sanity: the override is observably different from the default"
);
}
}