Skip to main content

koda_core/tools/
mod.rs

1//! Tool registry and execution engine.
2//!
3//! Each tool is a function that takes JSON arguments and returns a string result.
4//! Path validation is enforced here to prevent directory traversal.
5//!
6//! ## Available tools
7//!
8//! | Tool | Module | Effect | Description |
9//! |---|---|---|---|
10//! | **Read** | `file_tools` | ReadOnly | Read file contents with line numbers |
11//! | **Write** | `file_tools` | LocalMutation | Create or overwrite a file |
12//! | **Edit** | `file_tools` | LocalMutation | Find-and-replace in an existing file |
13//! | **Delete** | `file_tools` | Destructive | Delete a file |
14//! | **List** | `file_tools` | ReadOnly | List files and directories |
15//! | **Bash** | `shell` | LocalMutation | Execute shell commands (with background mode) |
16//! | **Grep** | `grep` | ReadOnly | Recursive text search (respects .gitignore) |
17//! | **Glob** | `glob_tool` | ReadOnly | Find files by glob pattern |
18//! | **WebFetch** | `web_fetch` | RemoteAction | Fetch URL content (HTML→text) |
19//! | **WebSearch** | `web_search` | RemoteAction | Web search via DuckDuckGo |
20//! | **InvokeAgent** | `agent` | LocalMutation | Delegate task to a sub-agent |
21//! | **ListAgents** | `agent` | ReadOnly | List available sub-agents |
22//! | **MemoryRead** | `memory` | ReadOnly | Read project/global memory |
23//! | **MemoryWrite** | `memory` | LocalMutation | Save facts to memory |
24//! | **TodoRead** | `todo` | ReadOnly | Read task list |
25//! | **TodoWrite** | `todo` | LocalMutation | Update task list |
26//! | **AskUser** | `ask_user` | ReadOnly | Ask the user a question |
27//! | **ActivateSkill** | `skills` | ReadOnly | Load a skill's instructions |
28//! | **ListSkills** | `skills` | ReadOnly | List available skills |
29//!
30//! ## Safety model
31//!
32//! Every tool call is classified by `ToolEffect` and checked against the
33//! current approval mode before execution. See
34//! `classify_tool` for the effect of each tool.
35
36/// Effect classification for tool calls.
37///
38/// Two-axis model: what does the tool touch (local vs. remote)
39/// and how severe are its effects (read vs. mutate vs. destroy)?
40///
41/// # Examples
42///
43/// ```
44/// use koda_core::tools::{ToolEffect, classify_tool};
45///
46/// assert_eq!(classify_tool("Read"), ToolEffect::ReadOnly);
47/// assert_eq!(classify_tool("Write"), ToolEffect::LocalMutation);
48/// assert_eq!(classify_tool("Delete"), ToolEffect::Destructive);
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51#[serde(rename_all = "PascalCase")]
52pub enum ToolEffect {
53    /// No side-effects: file reads, grep, git status.
54    ReadOnly,
55    /// Side-effects on remote services only: GitHub API, WebFetch POST.
56    RemoteAction,
57    /// Mutates local filesystem or state: Write, Edit, Delete, MemoryWrite.
58    LocalMutation,
59    /// Irreversible or high-blast-radius: rm -rf, git push --force, DROP TABLE.
60    Destructive,
61}
62
63/// Classify a built-in tool by name.
64///
65/// For `Bash`, this returns the *default* classification (`LocalMutation`);
66/// the actual effect depends on the command string and must be refined
67/// via [`crate::bash_safety::classify_bash_command`].
68///
69/// Unknown tools default to `LocalMutation` (conservative — always asks).
70///
71/// For MCP tools (names containing `__`), call
72/// [`ToolRegistry::classify_tool_with_mcp`] instead to use server-provided
73/// annotations.
74pub fn classify_tool(name: &str) -> ToolEffect {
75    match name {
76        // Pure reads — zero side-effects
77        "Read" | "List" | "Grep" | "Glob" | "MemoryRead" | "ListAgents" | "ListSkills"
78        | "ActivateSkill" | "RecallContext" | "AskUser" | "TodoRead" => ToolEffect::ReadOnly,
79
80        // Remote actions — side-effects on remote services only
81        "WebFetch" => ToolEffect::ReadOnly,    // GET-only fetch
82        "WebSearch" => ToolEffect::ReadOnly,   // read-only search
83        "InvokeAgent" => ToolEffect::ReadOnly, // sub-agents inherit parent's mode
84
85        // Local mutations — write to filesystem or local state
86        "Write" | "Edit" | "MemoryWrite" | "TodoWrite" => ToolEffect::LocalMutation,
87
88        // Bash — default to LocalMutation; refined by classify_bash_command()
89        "Bash" => ToolEffect::LocalMutation,
90
91        // Delete is destructive (irreversible without undo)
92        "Delete" => ToolEffect::Destructive,
93
94        // MCP tools — use annotations-based classification.
95        name if crate::mcp::is_mcp_tool_name(name) => ToolEffect::RemoteAction,
96
97        // Unknown tools — default to LocalMutation (conservative)
98        _ => ToolEffect::LocalMutation,
99    }
100}
101
102/// Returns true if the tool performs a mutating operation.
103///
104/// Convenience wrapper over [`classify_tool`] for call sites that only
105/// need a bool (e.g., loop guard).
106///
107/// ```
108/// use koda_core::tools::is_mutating_tool;
109///
110/// assert!(!is_mutating_tool("Read"));
111/// assert!(is_mutating_tool("Write"));
112/// assert!(is_mutating_tool("Delete"));
113/// ```
114pub fn is_mutating_tool(name: &str) -> bool {
115    !matches!(classify_tool(name), ToolEffect::ReadOnly)
116}
117
118/// Sub-agent invocation tool (`InvokeAgent`, `ListAgents`).
119pub mod agent;
120pub mod ask_user;
121pub mod bg_process;
122/// File CRUD tools (`Read`, `Write`, `Edit`, `Delete`, `List`).
123pub mod file_tools;
124pub mod fuzzy;
125/// Glob pattern search tool (`Glob`).
126pub mod glob_tool;
127/// Recursive text search tool (`Grep`).
128pub mod grep;
129/// Project memory read/write tools (`MemoryRead`, `MemoryWrite`).
130pub mod memory;
131/// On-demand conversation history retrieval (`RecallContext`).
132pub mod recall;
133/// Shell command execution tool (`Bash`).
134pub mod shell;
135/// Skill discovery and activation tools (`ListSkills`, `ActivateSkill`).
136pub mod skill_tools;
137/// Session-scoped task list tool (`TodoWrite`).
138pub mod todo;
139/// Pre-flight validation for tool calls (runs before approval).
140pub mod validate;
141/// HTTP fetch tool (`WebFetch`).
142pub mod web_fetch;
143/// Web search tool (`WebSearch`).
144pub mod web_search;
145
146use anyhow::Result;
147use path_clean::PathClean;
148use serde_json::Value;
149use std::collections::HashMap;
150use std::path::{Path, PathBuf};
151use std::sync::Arc;
152use std::time::SystemTime;
153
154use crate::output_caps::OutputCaps;
155
156use crate::providers::ToolDefinition;
157
158/// Shared file-read cache: tracks `(size, mtime, sha256_hex)` per cache key.
159///
160/// The SHA-256 field is populated on full-file reads and used by `edit_file`
161/// to detect whether the file changed between when the model last read it and
162/// when it attempts an edit (Gemini CLI strategy, better than mtime-only because
163/// mtime has 1-second granularity and can miss sub-second bash mutations).
164///
165/// `sha256_hex` is empty for line-range reads where only a slice was fetched.
166///
167/// Wrapped in `Arc` so parent and sub-agent `ToolRegistry` instances
168/// share the same cache — reads by one agent benefit all others.
169pub type FileReadCache = Arc<std::sync::Mutex<HashMap<String, (u64, SystemTime, String)>>>;
170
171/// Tracks which tool last wrote each absolute file path.
172///
173/// Keyed by canonical `PathBuf`; value is `(tool_name, when)` using a
174/// monotonic `Instant`. Populated on every successful Write and Edit so
175/// the validation layer can include the responsible tool in staleness
176/// error messages (#804 item 7).
177pub type LastWriterCache = Arc<std::sync::Mutex<HashMap<PathBuf, (String, std::time::Instant)>>>;
178
179/// Tracks the most recent successful Bash invocation.
180///
181/// Stores `(command_snippet, when)`. Only the latest call is kept — enough
182/// context to tell the model "Bash ran 2s ago, it may have changed the file".
183pub type LastBashCache = Arc<std::sync::Mutex<Option<(String, std::time::Instant)>>>;
184
185/// Result of executing a tool.
186///
187/// The `success` field is set automatically by `ToolRegistry::execute()` —
188/// `Ok(…)` → `true`, `Err(…)` → `false`. Individual tool functions just
189/// return `Result<String>`.
190///
191/// ```
192/// use koda_core::tools::ToolResult;
193///
194/// let ok = ToolResult { output: "done".into(), success: true, full_output: None };
195/// assert!(ok.success);
196/// ```
197#[derive(Debug, Clone)]
198pub struct ToolResult {
199    /// The tool's output string (model-facing; may be a summary for Bash).
200    pub output: String,
201    /// Whether the tool executed successfully.
202    ///
203    /// Set automatically by `ToolRegistry::execute()` — `Ok(…)` → `true`,
204    /// `Err(…)` → `false`. Individual tools never set this directly;
205    /// they just return `Result<String>`.
206    pub success: bool,
207    /// Full untruncated output, stored separately in DB for later retrieval.
208    ///
209    /// Only populated by Bash when output exceeds the summary threshold.
210    /// `RecallContext` can search this to retrieve details the model didn't
211    /// see in its context window.
212    pub full_output: Option<String>,
213}
214
215/// The tool registry: maps tool names to their definitions and handlers.
216pub struct ToolRegistry {
217    project_root: PathBuf,
218    definitions: HashMap<String, ToolDefinition>,
219    read_cache: FileReadCache,
220    /// Per-file last-writer tracking for richer staleness errors (#804 item 7).
221    last_writer: LastWriterCache,
222    /// Most recent Bash invocation for staleness error context (#804 item 7).
223    last_bash: LastBashCache,
224    /// Undo stack for file mutations.
225    pub undo: std::sync::Mutex<crate::undo::UndoStack>,
226    /// Discovered skills.
227    pub skill_registry: crate::skills::SkillRegistry,
228    /// Database handle for tools that need session access (RecallContext).
229    db: std::sync::RwLock<Option<std::sync::Arc<crate::db::Database>>>,
230    /// Current session ID (for RecallContext).
231    session_id: std::sync::RwLock<Option<String>>,
232    /// Context-scaled output caps for all tools.
233    pub caps: OutputCaps,
234    /// Background process registry — tracks processes spawned with `background: true`.
235    /// Dropped (SIGTERM all) when the session ends.
236    pub bg_registry: bg_process::BgRegistry,
237    /// Trust mode — determines sandbox configuration for Bash tool.
238    trust: crate::trust::TrustMode,
239    /// MCP connection manager — owns all MCP server connections (#662).
240    /// `None` until attached via `set_mcp_manager()`.
241    mcp_manager: std::sync::RwLock<Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>>>,
242}
243
244impl ToolRegistry {
245    /// Create a new registry with all built-in tools.
246    ///
247    /// `max_context_tokens` scales all output caps (see `OutputCaps`).
248    pub fn new(project_root: PathBuf, max_context_tokens: usize) -> Self {
249        Self::with_trust(
250            project_root,
251            max_context_tokens,
252            crate::trust::TrustMode::Safe,
253        )
254    }
255
256    /// Create a new registry with a specific trust mode.
257    pub fn with_trust(
258        project_root: PathBuf,
259        max_context_tokens: usize,
260        trust: crate::trust::TrustMode,
261    ) -> Self {
262        let mut definitions = HashMap::new();
263
264        // Register all built-in tools
265        for def in file_tools::definitions() {
266            definitions.insert(def.name.clone(), def);
267        }
268
269        for def in grep::definitions() {
270            definitions.insert(def.name.clone(), def);
271        }
272        for def in shell::definitions() {
273            definitions.insert(def.name.clone(), def);
274        }
275        for def in agent::definitions() {
276            definitions.insert(def.name.clone(), def);
277        }
278        for def in ask_user::definitions() {
279            definitions.insert(def.name.clone(), def);
280        }
281        for def in glob_tool::definitions() {
282            definitions.insert(def.name.clone(), def);
283        }
284        for def in web_fetch::definitions() {
285            definitions.insert(def.name.clone(), def);
286        }
287        for def in web_search::definitions() {
288            definitions.insert(def.name.clone(), def);
289        }
290        for def in todo::definitions() {
291            definitions.insert(def.name.clone(), def);
292        }
293        for def in memory::definitions() {
294            definitions.insert(def.name.clone(), def);
295        }
296        for def in skill_tools::definitions() {
297            definitions.insert(def.name.clone(), def);
298        }
299        // RecallContext — on-demand history retrieval
300        let recall_def = recall::definition();
301        definitions.insert(recall_def.name.clone(), recall_def);
302        let skill_registry = crate::skills::SkillRegistry::discover(&project_root);
303
304        Self {
305            project_root,
306            definitions,
307            read_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
308            last_writer: Arc::new(std::sync::Mutex::new(HashMap::new())),
309            last_bash: Arc::new(std::sync::Mutex::new(None)),
310            undo: std::sync::Mutex::new(crate::undo::UndoStack::new()),
311            skill_registry,
312            db: std::sync::RwLock::new(None),
313            session_id: std::sync::RwLock::new(None),
314            caps: OutputCaps::for_context(max_context_tokens),
315            bg_registry: bg_process::BgRegistry::new(),
316            trust,
317            mcp_manager: std::sync::RwLock::new(None),
318        }
319    }
320
321    /// Share an existing file-read cache (e.g. from the parent agent).
322    ///
323    /// Sub-agents that share the parent's cache avoid redundant disk reads
324    /// for files already loaded in the same session.
325    pub fn with_shared_cache(mut self, cache: FileReadCache) -> Self {
326        self.read_cache = cache;
327        self
328    }
329
330    /// Get a clone of the `Arc` file-read cache for sharing with sub-agents.
331    pub fn file_read_cache(&self) -> FileReadCache {
332        Arc::clone(&self.read_cache)
333    }
334
335    /// Get a clone of the last-writer cache for passing to validation.
336    pub fn last_writer_cache(&self) -> LastWriterCache {
337        Arc::clone(&self.last_writer)
338    }
339
340    /// Get a clone of the last-bash cache for passing to validation.
341    pub fn last_bash_cache(&self) -> LastBashCache {
342        Arc::clone(&self.last_bash)
343    }
344
345    /// Attach database + session for tools that need history access.
346    pub fn set_session(&self, db: std::sync::Arc<crate::db::Database>, session_id: String) {
347        if let Ok(mut guard) = self.db.write() {
348            *guard = Some(db);
349        }
350        if let Ok(mut guard) = self.session_id.write() {
351            *guard = Some(session_id);
352        }
353    }
354
355    /// Attach an MCP connection manager and register its tools (#662).
356    ///
357    /// Called after MCP servers have connected and discovered their tools.
358    /// Tool definitions are merged into the registry so the LLM can see them.
359    pub fn set_mcp_manager(&self, manager: Arc<tokio::sync::RwLock<crate::mcp::McpManager>>) {
360        if let Ok(mut guard) = self.mcp_manager.write() {
361            *guard = Some(manager);
362        }
363    }
364
365    /// Get the MCP manager (if attached).
366    pub fn mcp_manager(&self) -> Option<Arc<tokio::sync::RwLock<crate::mcp::McpManager>>> {
367        self.mcp_manager.read().ok().and_then(|guard| guard.clone())
368    }
369
370    /// Classify a tool, using MCP annotations when available.
371    ///
372    /// For built-in tools, delegates to `classify_tool()`.
373    /// For MCP tools, looks up cached annotations in the manager.
374    pub fn classify_tool_with_mcp(&self, name: &str) -> ToolEffect {
375        if crate::mcp::is_mcp_tool_name(name) {
376            if let Some(mgr) = self.mcp_manager()
377                && let Ok(mgr) = mgr.try_read()
378            {
379                return mgr.classify_tool(name);
380            }
381            // Fallback: no manager or lock contention.
382            return ToolEffect::RemoteAction;
383        }
384        classify_tool(name)
385    }
386
387    /// Get all built-in tool names.
388    /// Used by wiring tests to verify every tool is properly integrated.
389    pub fn all_builtin_tool_names(&self) -> Vec<String> {
390        let mut names: Vec<String> = self.definitions.keys().cloned().collect();
391        names.sort();
392        names
393    }
394
395    /// Check whether a tool name is known.
396    pub fn has_tool(&self, name: &str) -> bool {
397        self.definitions.contains_key(name)
398    }
399
400    /// List all available skills as `(name, description, source)` tuples.
401    pub fn list_skills(&self) -> Vec<(String, String, String)> {
402        self.skill_registry
403            .list()
404            .into_iter()
405            .map(|m| {
406                let source = match m.source {
407                    crate::skills::SkillSource::BuiltIn => "built-in",
408                    crate::skills::SkillSource::User => "user",
409                    crate::skills::SkillSource::Project => "project",
410                };
411                (m.name.clone(), m.description.clone(), source.to_string())
412            })
413            .collect()
414    }
415
416    /// Search skills by query, returning `(name, description, source)` tuples.
417    pub fn search_skills(&self, query: &str) -> Vec<(String, String, String)> {
418        self.skill_registry
419            .search(query)
420            .into_iter()
421            .map(|m| {
422                let source = match m.source {
423                    crate::skills::SkillSource::BuiltIn => "built-in",
424                    crate::skills::SkillSource::User => "user",
425                    crate::skills::SkillSource::Project => "project",
426                };
427                (m.name.clone(), m.description.clone(), source.to_string())
428            })
429            .collect()
430    }
431
432    /// Get tool definitions, optionally filtered by allow/deny lists.
433    ///
434    /// Includes MCP tool definitions if a manager is attached.
435    ///
436    /// - `allowed` non-empty → only those tools (allowlist).
437    /// - `denied` non-empty → all tools except those (denylist).
438    /// - Both empty → all tools.
439    /// - If both are specified, allowlist wins (deny is ignored).
440    pub fn get_definitions(&self, allowed: &[String], denied: &[String]) -> Vec<ToolDefinition> {
441        let mut defs: Vec<ToolDefinition> = if !allowed.is_empty() {
442            allowed
443                .iter()
444                .filter_map(|name| self.definitions.get(name).cloned())
445                .collect()
446        } else if !denied.is_empty() {
447            self.definitions
448                .values()
449                .filter(|d| !denied.contains(&d.name))
450                .cloned()
451                .collect()
452        } else {
453            self.definitions.values().cloned().collect()
454        };
455
456        // Append MCP tool definitions.
457        if let Some(mgr) = self.mcp_manager()
458            && let Ok(mgr) = mgr.try_read()
459        {
460            let mcp_defs = mgr.all_tool_definitions();
461            if !allowed.is_empty() {
462                // Allowlist mode: only include MCP tools in the allowlist.
463                for def in mcp_defs {
464                    if allowed.contains(&def.name) {
465                        defs.push(def);
466                    }
467                }
468            } else if !denied.is_empty() {
469                // Denylist mode: include MCP tools not in the denylist.
470                for def in mcp_defs {
471                    if !denied.contains(&def.name) {
472                        defs.push(def);
473                    }
474                }
475            } else {
476                // No filter: include all MCP tools.
477                defs.extend(mcp_defs);
478            }
479        }
480
481        defs
482    }
483
484    /// Execute a tool by name with the given JSON arguments.
485    ///
486    /// Empty or whitespace-only `arguments` are treated as `{}` (no args)
487    /// so that tools can fall through to their own defaults instead of
488    /// surfacing a raw JSON parse error.  See #513.
489    ///
490    /// `sink_for_streaming` is an optional `(sink, call_id)` pair. When
491    /// provided, the Bash tool streams each output line as a
492    /// `ToolOutputLine` event in real-time.
493    pub async fn execute(
494        &self,
495        name: &str,
496        arguments: &str,
497        sink_for_streaming: Option<(&dyn crate::engine::EngineSink, &str)>,
498    ) -> ToolResult {
499        let raw = arguments.trim();
500        let raw = if raw.is_empty() { "{}" } else { raw };
501        let args: Value = match serde_json::from_str(raw) {
502            Ok(v) => v,
503            Err(e) => {
504                return ToolResult {
505                    output: format!("Invalid JSON arguments: {e}"),
506                    success: false,
507                    full_output: None,
508                };
509            }
510        };
511
512        tracing::info!(
513            "Executing tool: {name} with args: [{} chars]",
514            arguments.len()
515        );
516
517        // Snapshot file before mutation (for /undo)
518        if let Some(file_path) = crate::undo::is_mutating_tool(name)
519            .then(|| crate::undo::extract_file_path(name, &args))
520            .flatten()
521        {
522            let resolved = self.project_root.join(&file_path);
523            if let Ok(mut undo) = self.undo.lock() {
524                undo.snapshot(&resolved);
525            }
526        }
527
528        let result = match name {
529            // File tools
530            "Read" => file_tools::read_file(&self.project_root, &args, &self.read_cache).await,
531            "Write" => file_tools::write_file(&self.project_root, &args).await,
532            "Edit" => file_tools::edit_file(&self.project_root, &args, &self.read_cache).await,
533            "Delete" => file_tools::delete_file(&self.project_root, &args).await,
534            "List" => {
535                file_tools::list_files(&self.project_root, &args, self.caps.list_entries).await
536            }
537
538            // Search tools
539            "Grep" => grep::grep(&self.project_root, &args, self.caps.grep_matches).await,
540            "Glob" => {
541                glob_tool::glob_search(&self.project_root, &args, self.caps.glob_results).await
542            }
543
544            // Shell
545            // Shell — returns ShellOutput with summary + full output.
546            "Bash" => {
547                let shell_result = shell::run_shell_command(
548                    &self.project_root,
549                    &args,
550                    self.caps.shell_output_lines,
551                    &self.bg_registry,
552                    sink_for_streaming,
553                    &self.trust,
554                )
555                .await;
556                return match shell_result {
557                    Ok(so) => {
558                        // Record the invocation so validate_edit can hint at it
559                        // in staleness error messages (#804 item 7).
560                        let snippet = args["command"]
561                            .as_str()
562                            .unwrap_or("")
563                            .chars()
564                            .take(72)
565                            .collect::<String>();
566                        if !snippet.is_empty()
567                            && let Ok(mut guard) = self.last_bash.lock()
568                        {
569                            *guard = Some((snippet, std::time::Instant::now()));
570                        }
571                        ToolResult {
572                            output: so.summary,
573                            success: true,
574                            full_output: so.full_output,
575                        }
576                    }
577                    Err(e) => ToolResult {
578                        output: format!("Error: {e}"),
579                        success: false,
580                        full_output: None,
581                    },
582                };
583            }
584
585            // Web
586            "WebFetch" => web_fetch::web_fetch(&args, self.caps.web_body_chars).await,
587            "WebSearch" => web_search::web_search(&args).await,
588            "TodoWrite" => {
589                let db_opt = self.db.read().ok().and_then(|g| g.clone());
590                let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
591                match (db_opt, sid_opt) {
592                    (Some(db), Some(sid)) => todo::todo_write(&db, &sid, &args).await,
593                    _ => Ok("TodoWrite requires an active session.".to_string()),
594                }
595            }
596
597            // Memory
598            "MemoryRead" => memory::memory_read(&self.project_root).await,
599            "MemoryWrite" => memory::memory_write(&self.project_root, &args).await,
600
601            // Agent tools
602            "ListAgents" => {
603                let detail = args["detail"].as_bool().unwrap_or(false);
604                if detail {
605                    Ok(agent::list_agents_detail(&self.project_root))
606                } else {
607                    let agents = agent::list_agents(&self.project_root);
608                    if agents.is_empty() {
609                        Ok("No sub-agents configured.".to_string())
610                    } else {
611                        let lines: Vec<String> = agents
612                            .iter()
613                            .map(|(name, desc, source)| {
614                                if source == "built-in" {
615                                    format!("  {name} — {desc}")
616                                } else {
617                                    format!("  {name} — {desc} [{source}]")
618                                }
619                            })
620                            .collect();
621                        Ok(lines.join("\n"))
622                    }
623                }
624            }
625            // Skill tools
626            "ListSkills" => Ok(skill_tools::list_skills(&self.skill_registry, &args)),
627            "ActivateSkill" => Ok(skill_tools::activate_skill(&self.skill_registry, &args)),
628
629            // Recall context tool
630            "RecallContext" => {
631                let db_opt = self.db.read().ok().and_then(|g| g.clone());
632                let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
633                if let (Some(db), Some(sid)) = (db_opt, sid_opt) {
634                    Ok(recall::recall_context(&db, &sid, &args).await)
635                } else {
636                    Ok("RecallContext requires an active session.".to_string())
637                }
638            }
639
640            "InvokeAgent" => {
641                // Handled by tool_dispatch.rs before reaching here.
642                // This branch should not be reached in normal flow.
643                return ToolResult {
644                    output: "InvokeAgent is handled by the inference loop.".to_string(),
645                    success: false,
646                    full_output: None,
647                };
648            }
649
650            "AskUser" => {
651                // Handled by execute_tools_sequential (needs sink + cmd_rx).
652                // This branch should not be reached in normal flow.
653                return ToolResult {
654                    output: "AskUser is handled by the inference loop.".to_string(),
655                    success: false,
656                    full_output: None,
657                };
658            }
659
660            other => {
661                // MCP tool dispatch (#662): route `server__tool` calls
662                // to the appropriate MCP server.
663                if crate::mcp::is_mcp_tool_name(other) {
664                    if let Some(mgr) = self.mcp_manager() {
665                        let result = {
666                            let mgr = mgr.read().await;
667                            mgr.call_tool(other, args.clone()).await
668                        };
669                        return match result {
670                            Ok(output) => ToolResult {
671                                output,
672                                success: true,
673                                full_output: None,
674                            },
675                            Err(e) => ToolResult {
676                                output: format!("Error: {e}"),
677                                success: false,
678                                full_output: None,
679                            },
680                        };
681                    }
682                    return ToolResult {
683                        output: format!(
684                            "MCP tool '{other}' not available — \
685                             no MCP servers connected."
686                        ),
687                        success: false,
688                        full_output: None,
689                    };
690                }
691
692                // Detect garbled tool names (JSON blobs, very long strings)
693                // — a sign the model can't do structured tool calling.
694                let warning = if other.contains('{') || other.len() > 64 {
695                    format!(
696                        "Unknown tool: {other}. \
697                         This model appears to struggle with tool calling. \
698                         Consider switching to a model with native function-call support."
699                    )
700                } else {
701                    format!("Unknown tool: {other}")
702                };
703                Err(anyhow::anyhow!(warning))
704            }
705        };
706
707        match result {
708            Ok(output) => {
709                // Record successful Write/Edit so the validation layer can
710                // name the responsible tool in staleness error messages.
711                if matches!(name, "Write" | "Edit")
712                    && let Some(path) =
713                        crate::file_tracker::resolve_file_path_from_args(&args, &self.project_root)
714                    && let Ok(mut guard) = self.last_writer.lock()
715                {
716                    guard.insert(path, (name.to_string(), std::time::Instant::now()));
717                }
718                ToolResult {
719                    output,
720                    success: true,
721                    full_output: None,
722                }
723            }
724            Err(e) => ToolResult {
725                output: format!("Error: {e}"),
726                success: false,
727                full_output: None,
728            },
729        }
730    }
731}
732
733/// Validate and resolve a path, preventing directory traversal.
734///
735/// Works for both existing and non-existing files (no `canonicalize!`).
736/// Relative paths are joined to `project_root`; absolute paths must
737/// still be within `project_root`.
738///
739/// # Examples
740///
741/// ```
742/// use koda_core::tools::safe_resolve_path;
743/// use std::path::Path;
744///
745/// let root = Path::new("/home/user/project");
746///
747/// // Relative paths resolve within project
748/// let p = safe_resolve_path(root, "src/main.rs").unwrap();
749/// assert_eq!(p, Path::new("/home/user/project/src/main.rs"));
750///
751/// // Traversal is blocked
752/// assert!(safe_resolve_path(root, "../../etc/passwd").is_err());
753/// ```
754pub fn safe_resolve_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
755    // NOTE: used only for Write / Edit / Delete.  Read-only tools call
756    // resolve_path_unrestricted — see docs/src/sandbox.md for the rationale.
757    let requested_path = Path::new(requested);
758
759    // Build absolute path and normalize (removes .., . etc.)
760    let resolved = if requested_path.is_absolute() {
761        requested_path.to_path_buf().clean()
762    } else {
763        project_root.join(requested_path).clean()
764    };
765
766    // Security check: must be within project root.
767    // Only Write / Edit / Delete are gated here — reads are unrestricted
768    // (see resolve_path_unrestricted and docs/src/sandbox.md).
769    if !resolved.starts_with(project_root) {
770        anyhow::bail!(
771            "Path {requested:?} is outside the project root ({project_root:?}). \
772             Write, Edit, and Delete are restricted to the project directory to \
773             prevent accidental modification of files outside the project. \
774             Tell the user: to write outside the project, restart koda from a \
775             parent directory that contains both paths."
776        );
777    }
778
779    Ok(resolved)
780}
781
782/// Normalise a path without enforcing any scope restriction.
783///
784/// Low-level primitive — **tool implementations should call
785/// [`resolve_read_path`] instead**, which adds the fully-denied list check
786/// that keeps in-process policy in sync with the subprocess sandbox.
787///
788/// Relative paths are resolved against `project_root`; absolute paths are
789/// cleaned in-place.  The result may point anywhere on the filesystem.
790pub(crate) fn resolve_path_unrestricted(project_root: &Path, requested: &str) -> PathBuf {
791    let path = Path::new(requested);
792    if path.is_absolute() {
793        path.to_path_buf().clean()
794    } else {
795        project_root.join(path).clean()
796    }
797}
798
799/// Normalise a read-only path and enforce the fully-denied list.
800///
801/// This is the entry-point for **all read-only tools** (Read, List, Grep,
802/// Glob).  It wraps `resolve_path_unrestricted` with a check against
803/// `sandbox::is_fully_denied` so that the same paths blocked by the
804/// subprocess sandbox (bwrap / Seatbelt) are also blocked when the model
805/// accesses them through in-process tools.
806///
807/// Currently the only denied path is `~/.config/koda/db` — koda's own SQLite
808/// database containing plaintext API keys.  Ordinary credential directories
809/// (`~/.ssh`, `~/.aws`, …) are readable, matching the Bash sandbox policy.
810///
811/// See issue #884 for Option B (OS-level enforcement via sandboxed worker).
812pub fn resolve_read_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
813    let resolved = resolve_path_unrestricted(project_root, requested);
814    if crate::sandbox::is_fully_denied(&resolved) {
815        anyhow::bail!(
816            "Access to {requested:?} is denied: this path contains koda's \
817             internal secrets and cannot be read by model tool calls."
818        );
819    }
820    Ok(resolved)
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use std::path::PathBuf;
827
828    fn root() -> PathBuf {
829        PathBuf::from("/home/user/project")
830    }
831
832    #[test]
833    fn test_relative_path_resolves_inside_root() {
834        let result = safe_resolve_path(&root(), "src/main.rs").unwrap();
835        assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
836    }
837
838    #[test]
839    fn test_dot_path_resolves_to_root() {
840        let result = safe_resolve_path(&root(), ".").unwrap();
841        assert_eq!(result, PathBuf::from("/home/user/project"));
842    }
843
844    #[test]
845    fn test_new_file_in_new_dir_resolves() {
846        let result = safe_resolve_path(&root(), "src/brand_new/feature.rs").unwrap();
847        assert_eq!(
848            result,
849            PathBuf::from("/home/user/project/src/brand_new/feature.rs")
850        );
851    }
852
853    #[test]
854    fn test_dotdot_traversal_blocked() {
855        let result = safe_resolve_path(&root(), "../../etc/passwd");
856        assert!(result.is_err());
857    }
858
859    #[test]
860    fn test_dotdot_sneaky_traversal_blocked() {
861        let result = safe_resolve_path(&root(), "src/../../etc/passwd");
862        assert!(result.is_err());
863    }
864
865    #[test]
866    fn test_absolute_path_inside_root_allowed() {
867        let result = safe_resolve_path(&root(), "/home/user/project/src/lib.rs").unwrap();
868        assert_eq!(result, PathBuf::from("/home/user/project/src/lib.rs"));
869    }
870
871    #[test]
872    fn test_absolute_path_outside_root_blocked() {
873        let result = safe_resolve_path(&root(), "/etc/shadow");
874        assert!(result.is_err());
875    }
876
877    #[test]
878    fn test_outside_root_error_is_actionable_for_user() {
879        let err = safe_resolve_path(&root(), "../../etc/passwd").unwrap_err();
880        let msg = err.to_string();
881        assert!(
882            msg.contains("outside the project root"),
883            "error must say 'outside the project root'; got: {msg}"
884        );
885        assert!(
886            msg.contains("Tell the user"),
887            "error must direct model to surface this to the user; got: {msg}"
888        );
889        // Must NOT suggest Bash — that would bypass the file-tool safety layer.
890        assert!(
891            !msg.contains("Bash"),
892            "error must not suggest Bash as a workaround; got: {msg}"
893        );
894    }
895
896    #[test]
897    fn test_empty_path_resolves_to_root() {
898        let result = safe_resolve_path(&root(), "").unwrap();
899        assert_eq!(result, PathBuf::from("/home/user/project"));
900    }
901
902    // ── resolve_read_path ──────────────────────────────────────────────────
903
904    #[test]
905    fn read_path_allows_project_file() {
906        let p = resolve_read_path(&root(), "src/lib.rs").unwrap();
907        assert_eq!(p, PathBuf::from("/home/user/project/src/lib.rs"));
908    }
909
910    #[test]
911    fn read_path_allows_outside_project() {
912        // Reads outside the project root are intentionally unrestricted.
913        let p = resolve_read_path(&root(), "/etc/hosts").unwrap();
914        assert_eq!(p, PathBuf::from("/etc/hosts"));
915    }
916
917    #[test]
918    fn read_path_blocks_koda_db() {
919        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".into());
920        let koda_db = format!("{home}/.config/koda/db/koda.db");
921        let err = resolve_read_path(&root(), &koda_db).unwrap_err();
922        assert!(
923            err.to_string().contains("denied"),
924            "expected 'denied' in error, got: {err}"
925        );
926    }
927}
928
929// ── Tool action descriptions ──────────────────────────────────
930
931/// Generate a human-readable description of a tool action for approval prompts.
932pub fn describe_action(tool_name: &str, args: &serde_json::Value) -> String {
933    match tool_name {
934        "Bash" => {
935            let cmd = args
936                .get("command")
937                .or(args.get("cmd"))
938                .and_then(|v| v.as_str())
939                .unwrap_or("?");
940            let bg = args
941                .get("background")
942                .and_then(|v| v.as_bool())
943                .unwrap_or(false);
944            if bg {
945                format!("[bg] {cmd}")
946            } else {
947                cmd.to_string()
948            }
949        }
950        "Delete" => {
951            let path = args
952                .get("file_path")
953                .or(args.get("path"))
954                .and_then(|v| v.as_str())
955                .unwrap_or("?");
956            let recursive = args
957                .get("recursive")
958                .and_then(|v| v.as_bool())
959                .unwrap_or(false);
960            if recursive {
961                format!("Delete directory (recursive): {path}")
962            } else {
963                format!("Delete: {path}")
964            }
965        }
966        "Write" => {
967            let path = args
968                .get("path")
969                .or(args.get("file_path"))
970                .and_then(|v| v.as_str())
971                .unwrap_or("?");
972            let overwrite = args
973                .get("overwrite")
974                .and_then(|v| v.as_bool())
975                .unwrap_or(false);
976            if overwrite {
977                format!("Overwrite file: {path}")
978            } else {
979                format!("Create file: {path}")
980            }
981        }
982        "Edit" => {
983            let path = if let Some(payload) = args.get("payload") {
984                payload
985                    .get("file_path")
986                    .or(payload.get("path"))
987                    .and_then(|v| v.as_str())
988                    .unwrap_or("?")
989            } else {
990                args.get("file_path")
991                    .or(args.get("path"))
992                    .and_then(|v| v.as_str())
993                    .unwrap_or("?")
994            };
995            format!("Edit file: {path}")
996        }
997        "WebFetch" => {
998            let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("?");
999            format!("Fetch URL: {url}")
1000        }
1001        "WebSearch" => {
1002            let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("?");
1003            format!("Web search: {q}")
1004        }
1005        "TodoWrite" => {
1006            let n = args
1007                .get("todos")
1008                .and_then(|v| v.as_array())
1009                .map(|a| a.len())
1010                .unwrap_or(0);
1011            format!("Update todo list ({n} tasks)")
1012        }
1013        "MemoryWrite" => {
1014            let fact = args.get("fact").and_then(|v| v.as_str()).unwrap_or("?");
1015            let preview = if fact.len() > 60 {
1016                format!("{}…", &fact[..57])
1017            } else {
1018                fact.to_string()
1019            };
1020            format!("Save to memory: {preview}")
1021        }
1022        _ => format!("Execute: {tool_name}"),
1023    }
1024}
1025
1026#[cfg(test)]
1027mod describe_action_tests {
1028    use super::*;
1029    use serde_json::json;
1030
1031    #[test]
1032    fn test_describe_bash() {
1033        let desc = describe_action("Bash", &json!({"command": "cargo build"}));
1034        assert!(desc.contains("cargo build"));
1035    }
1036
1037    #[test]
1038    fn test_describe_delete() {
1039        let desc = describe_action("Delete", &json!({"file_path": "old.rs"}));
1040        assert!(desc.contains("old.rs"));
1041    }
1042
1043    #[test]
1044    fn test_describe_edit() {
1045        let desc = describe_action("Edit", &json!({"payload": {"file_path": "src/main.rs"}}));
1046        assert!(desc.contains("src/main.rs"));
1047    }
1048
1049    #[test]
1050    fn test_describe_write() {
1051        let desc = describe_action("Write", &json!({"path": "new.rs"}));
1052        assert!(desc.contains("Create file"));
1053        assert!(desc.contains("new.rs"));
1054    }
1055
1056    #[test]
1057    fn test_describe_write_overwrite() {
1058        let desc = describe_action("Write", &json!({"path": "x.rs", "overwrite": true}));
1059        assert!(desc.contains("Overwrite"));
1060    }
1061
1062    #[test]
1063    fn test_get_definitions_deny_list() {
1064        let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1065        let denied = vec![
1066            "Write".to_string(),
1067            "Edit".to_string(),
1068            "Delete".to_string(),
1069        ];
1070        let defs = registry.get_definitions(&[], &denied);
1071        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1072        assert!(!names.contains(&"Write"));
1073        assert!(!names.contains(&"Edit"));
1074        assert!(!names.contains(&"Delete"));
1075        assert!(names.contains(&"Read"));
1076        assert!(names.contains(&"Grep"));
1077    }
1078
1079    #[test]
1080    fn test_get_definitions_allow_list_wins_over_deny() {
1081        let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1082        let allowed = vec!["Read".to_string(), "Write".to_string()];
1083        let denied = vec!["Write".to_string()];
1084        // allow wins — Write should be present
1085        let defs = registry.get_definitions(&allowed, &denied);
1086        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1087        assert_eq!(names.len(), 2);
1088        assert!(names.contains(&"Read"));
1089        assert!(names.contains(&"Write"));
1090    }
1091
1092    #[test]
1093    fn test_get_definitions_both_empty_returns_all() {
1094        let registry = ToolRegistry::new(PathBuf::from("/tmp"), 128_000);
1095        let all = registry.get_definitions(&[], &[]);
1096        assert!(all.len() > 10, "Should have many tools");
1097    }
1098}