#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum ToolEffect {
ReadOnly,
RemoteAction,
LocalMutation,
Destructive,
}
pub fn classify_tool(name: &str) -> ToolEffect {
match name {
"Read" | "List" | "Grep" | "Glob" | "MemoryRead" | "ListAgents" | "ListSkills"
| "ActivateSkill" | "RecallContext" | "AskUser" | "TodoRead" => ToolEffect::ReadOnly,
"WebFetch" => ToolEffect::ReadOnly, "WebSearch" => ToolEffect::ReadOnly, "InvokeAgent" => ToolEffect::ReadOnly,
"ListBackgroundTasks" | "CancelTask" | "WaitTask" => ToolEffect::ReadOnly,
"Write" | "Edit" | "MemoryWrite" | "TodoWrite" => ToolEffect::LocalMutation,
"Bash" => ToolEffect::LocalMutation,
"Delete" => ToolEffect::Destructive,
name if crate::mcp::is_mcp_tool_name(name) => ToolEffect::RemoteAction,
_ => ToolEffect::LocalMutation,
}
}
pub fn is_mutating_tool(name: &str) -> bool {
!matches!(classify_tool(name), ToolEffect::ReadOnly)
}
pub mod agent;
pub mod ask_user;
pub mod bg_process;
pub mod bg_task_tools;
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 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 struct ToolRegistry {
project_root: PathBuf,
definitions: HashMap<String, ToolDefinition>,
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,
mcp_manager: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>>>,
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 mut definitions = HashMap::new();
for def in file_tools::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in grep::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in shell::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in agent::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in bg_task_tools::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in ask_user::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in glob_tool::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in web_fetch::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in web_search::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in todo::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in memory::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in skill_tools::definitions() {
definitions.insert(def.name.clone(), def);
}
let recall_def = recall::definition();
definitions.insert(recall_def.name.clone(), recall_def);
let skill_registry = crate::skills::SkillRegistry::discover(&project_root);
Self {
project_root,
definitions,
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(),
mcp_manager: std::sync::RwLock::new(None),
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>>) {
if let Ok(mut guard) = self.mcp_manager.write() {
*guard = Some(manager);
}
}
pub fn mcp_manager(&self) -> Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>> {
self.mcp_manager.read().ok().and_then(|guard| guard.clone())
}
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 classify_tool_with_mcp(&self, name: &str) -> ToolEffect {
if crate::mcp::is_mcp_tool_name(name) {
if let Some(mgr) = self.mcp_manager()
&& let Ok(mgr) = mgr.try_read()
{
return mgr.classify_tool(name);
}
return ToolEffect::RemoteAction;
}
classify_tool(name)
}
pub fn all_builtin_tool_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.definitions.keys().cloned().collect();
names.sort();
names
}
pub fn has_tool(&self, name: &str) -> bool {
self.definitions.contains_key(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> {
let mut defs: Vec<ToolDefinition> = if !allowed.is_empty() {
allowed
.iter()
.filter_map(|name| self.definitions.get(name).cloned())
.collect()
} else if !denied.is_empty() {
self.definitions
.values()
.filter(|d| !denied.contains(&d.name))
.cloned()
.collect()
} else {
self.definitions.values().cloned().collect()
};
if let Some(mgr) = self.mcp_manager()
&& let Ok(mgr) = mgr.try_read()
{
let mcp_defs = mgr.all_tool_definitions();
if !allowed.is_empty() {
for def in mcp_defs {
if allowed.contains(&def.name) {
defs.push(def);
}
}
} else if !denied.is_empty() {
for def in mcp_defs {
if !denied.contains(&def.name) {
defs.push(def);
}
}
} else {
defs.extend(mcp_defs);
}
}
defs
}
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()
);
if let Some(file_path) = crate::undo::is_mutating_tool(name)
.then(|| crate::undo::extract_file_path(name, &args))
.flatten()
{
let resolved = self.project_root.join(&file_path);
if let Ok(mut undo) = self.undo.lock() {
undo.snapshot(&resolved);
}
}
let result = match name {
"Read" => {
file_tools::read_file(&self.project_root, &args, &self.read_cache, &*self.fs).await
}
"Write" => file_tools::write_file(&self.project_root, &args, &*self.fs).await,
"Edit" => {
file_tools::edit_file(&self.project_root, &args, &self.read_cache, &*self.fs).await
}
"Delete" => file_tools::delete_file(&self.project_root, &args).await,
"List" => {
file_tools::list_files(&self.project_root, &args, self.caps.list_entries).await
}
"Grep" => {
grep::grep(&self.project_root, &args, self.caps.grep_matches, &*self.fs).await
}
"Glob" => {
glob_tool::glob_search(&self.project_root, &args, self.caps.glob_results, &*self.fs)
.await
}
"Bash" => {
let shell_result = shell::run_shell_command(
&self.project_root,
&args,
self.caps.shell_output_lines,
&self.bg_registry,
sink_for_streaming,
&self.trust,
self.sandbox_policy(),
self.proxy_port(),
self.socks5_port(),
caller_spawner,
)
.await;
return match shell_result {
Ok(so) => {
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()));
}
ToolResult {
output: so.summary,
success: true,
full_output: so.full_output,
}
}
Err(e) => ToolResult {
output: format!("Error: {e}"),
success: false,
full_output: None,
},
};
}
"WebFetch" => web_fetch::web_fetch(&args, self.caps.web_body_chars).await,
"WebSearch" => web_search::web_search(&args).await,
"TodoWrite" => {
let db_opt = self.db.read().ok().and_then(|g| g.clone());
let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
match (db_opt, sid_opt) {
(Some(db), Some(sid)) => todo::todo_write(&db, &sid, &args).await,
_ => Ok("TodoWrite requires an active session.".to_string()),
}
}
"MemoryRead" => memory::memory_read(&self.project_root).await,
"MemoryWrite" => memory::memory_write(&self.project_root, &args).await,
"ListAgents" => {
let detail = args["detail"].as_bool().unwrap_or(false);
if detail {
Ok(agent::list_agents_detail(&self.project_root))
} else {
let agents = agent::list_agents(&self.project_root);
if agents.is_empty() {
Ok("No sub-agents configured.".to_string())
} else {
let lines: Vec<String> = agents
.iter()
.map(|(name, desc, source)| {
if source == "built-in" {
format!(" {name} — {desc}")
} else {
format!(" {name} — {desc} [{source}]")
}
})
.collect();
Ok(lines.join("\n"))
}
}
}
"ListSkills" => Ok(skill_tools::list_skills(&self.skill_registry, &args)),
"ActivateSkill" => Ok(skill_tools::activate_skill(&self.skill_registry, &args)),
"RecallContext" => {
let db_opt = self.db.read().ok().and_then(|g| g.clone());
let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
if let (Some(db), Some(sid)) = (db_opt, sid_opt) {
Ok(recall::recall_context(&db, &sid, &args).await)
} else {
Ok("RecallContext requires an active session.".to_string())
}
}
"InvokeAgent" => {
return ToolResult {
output: "InvokeAgent is handled by the inference loop.".to_string(),
success: false,
full_output: None,
};
}
"AskUser" => {
return ToolResult {
output: "AskUser is handled by the inference loop.".to_string(),
success: false,
full_output: None,
};
}
other => {
if crate::mcp::is_mcp_tool_name(other) {
if let Some(mgr) = self.mcp_manager() {
let result = {
let mgr = mgr.read().await;
mgr.call_tool(other, 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 '{other}' not available — \
no MCP servers connected."
),
success: false,
full_output: None,
};
}
let warning = if other.contains('{') || other.len() > 64 {
format!(
"Unknown tool: {other}. \
This model appears to struggle with tool calling. \
Consider switching to a model with native function-call support."
)
} else {
format!("Unknown tool: {other}")
};
Err(anyhow::anyhow!(warning))
}
};
match result {
Ok(output) => {
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()));
}
ToolResult {
output,
success: true,
full_output: None,
}
}
Err(e) => ToolResult {
output: format!("Error: {e}"),
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(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"));
}
}
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"
);
}
}