//! Tool trait, execution context, and the registry of all built-in tools. use std::path::PathBuf; use std::sync::{Arc, Mutex}; use serde_json::Value; use anyhow::Result; pub mod bash_tools; pub mod fs; pub mod git_cred; pub mod git_operator; pub mod git_worktree; pub mod lsp; pub mod memory; pub mod plan; pub mod search; pub mod seqthink; pub mod shell; pub mod shell_filter; pub mod utility; pub mod workflow; /// Common interface every agent-invocable tool implements: name, JSON schema, and execution. pub trait Tool: Send + Sync { fn name(&self) -> &'static str; fn description(&self) -> &'static str; fn parameters(&self) -> Value; fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; } /// A project-defined rule that flags a matching file path or content pattern for review. #[derive(Debug, Clone)] pub struct GraduatedCheck { pub name: String, pub pattern: String, pub rule: String, } /// Shared execution context passed to every `Tool::run` call: workspace roots, session /// paths, and cached directory state. #[derive(Clone)] pub struct ToolCtx { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub _download_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, } /// Find which graduated checks apply to a given file path/content pair. /// /// Flow: for each check, match its `pattern` against `path` or its `rule` against /// `content` (substring match) → collect matching check names. /// /// Return: names of all matching checks; empty if none match. pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { let mut matches = Vec::new(); for check in checks { if path.contains(&check.pattern) || content.contains(&check.rule) { matches.push(check.name.clone()); } } matches } impl ToolCtx { /// Start building a `ToolCtx` with `ToolCtxBuilder`'s defaults. pub fn builder() -> ToolCtxBuilder { ToolCtxBuilder::default() } } /// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. pub struct ToolCtxBuilder { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub download_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, } impl Default for ToolCtxBuilder { fn default() -> Self { ToolCtxBuilder { workspaces: Vec::new(), session_dir: PathBuf::new(), memory_dir: PathBuf::new(), download_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())), origin: crate::app::state::types::Origin::Main, graduated_checks: Vec::new(), lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())), } } } impl ToolCtxBuilder { /// Set the session directory. pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } /// Set the origin (main process vs. daemon-attached). pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } /// Set the lsp_manager. #[allow(dead_code)] pub fn lsp_manager(mut self, v: Arc>) -> Self { self.lsp_manager = v; self } /// Consume the builder and produce the final `ToolCtx`. pub fn build(self) -> ToolCtx { ToolCtx { workspaces: self.workspaces, session_dir: self.session_dir, memory_dir: self.memory_dir, _download_dir: self.download_dir, worktrees_dir: self.worktrees_dir, dir_cache: self.dir_cache, origin: self.origin, graduated_checks: self.graduated_checks, lsp_manager: self.lsp_manager, } } } /// Construct one instance of every built-in tool, in the fixed order exposed to the LLM. /// /// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan, /// workflow, utility). pub fn all_tools() -> Vec> { vec![ Box::new(super::tool::fs::read::Read), Box::new(super::tool::fs::write::Write), Box::new(super::tool::fs::edit::Edit), Box::new(super::tool::fs::delete::Delete), Box::new(super::tool::search::Grep), Box::new(super::tool::search::Glob), Box::new(super::tool::bash_tools::BashOutput), Box::new(super::tool::bash_tools::BashKill), Box::new(super::tool::shell::Bash), Box::new(super::tool::git_operator::GitOperator), Box::new(super::tool::git_worktree::GitWorktree), Box::new(super::tool::git_cred::GitCred), Box::new(super::tool::seqthink::SeqThink), Box::new(super::tool::plan::PlanEnter), Box::new(super::tool::plan::PlanReady), Box::new(super::tool::workflow::WorkflowRun), Box::new(super::tool::workflow::NoteFinding), Box::new(super::tool::memory::remember::Remember), Box::new(super::tool::memory::forget::Forget), Box::new(super::tool::memory::recall::Recall), Box::new(super::tool::utility::cd::Cd), Box::new(super::tool::utility::dir_list::DirList), Box::new(super::tool::utility::dir_cache_update::DirCacheUpdate), Box::new(super::tool::utility::pong::Pong), Box::new(super::tool::utility::todowrite::Todowrite), Box::new(super::tool::utility::todofinish::Todofinish), Box::new(super::tool::lsp::LspConnect), Box::new(super::tool::lsp::LspDiagnostics), Box::new(super::tool::lsp::LspHover), Box::new(super::tool::lsp::LspCompletion), Box::new(super::tool::lsp::LspDefinition), Box::new(super::tool::lsp::LspReferences), Box::new(super::tool::lsp::LspDisconnect), ] } /// Whether a tool by name can mutate the filesystem or run arbitrary shell commands. /// /// Why: used by the harness to decide which tool calls need user confirmation/guard checks. pub fn tool_is_risky(name: &str) -> bool { matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") } /// Convert a list of tools into the provider-facing `ToolDef` request schema. /// /// Return: one `ToolDef` per tool, in the same order as `tools`. pub fn tool_defs(tools: &[Box]) -> Vec { tools .iter() .map(|t| crate::dto::provider::request::ToolDef { type_: "function".to_string(), function: crate::dto::provider::request::ToolFunctionDef { name: t.name().to_string(), description: t.description().to_string(), parameters: t.parameters(), }, }) .collect() } /// Resolve a tool-supplied relative path to an absolute path within a workspace root, /// rejecting escapes. /// /// Flow: parse optional `[N]` workspace-index prefix (defaults to workspace 0) → join /// remainder onto that workspace root → canonicalize → verify the canonical path /// still starts with one of `workspaces`. /// /// Why: canonicalizing and re-checking containment (rather than trusting the join) /// prevents `../` traversal from escaping the sandboxed workspace roots. /// /// Return: the canonical absolute path, or an error if the workspace index is invalid /// or the resolved path falls outside all workspace roots. pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { let _parts: Vec<&str> = rel.splitn(2, '/').collect(); let (ws_idx, path) = if rel.starts_with('[') { let close = rel.find(']').ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; let idx: usize = rel[1..close].parse().map_err(|_| anyhow::anyhow!("invalid workspace index"))?; (idx, &rel[close + 1..]) } else { (0, rel) }; let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?; let abs = if path.is_empty() { base.clone() } else { base.join(path) }; // Resolve the path with canonicalisation. For non-existent files // (e.g. the write tool creating a new file), canonicalise the base // workspace root first and then resolve parent-dir (`../`) traversal // component-by-component so that `Path::starts_with` cannot be // bypassed by unnormalised intermediate segments. let canon = match abs.canonicalize() { Ok(c) => c, Err(_) => { let base_canon = workspaces .iter() .filter_map(|w| w.canonicalize().ok()) .next() .unwrap_or_else(|| base.clone()); let mut resolved = base_canon.clone(); if let Ok(rel_components) = abs.strip_prefix(&base_canon) { for comp in rel_components.components() { match comp { std::path::Component::ParentDir => { resolved.pop(); } std::path::Component::CurDir => {} c => resolved.push(c), } } } resolved } }; if workspaces.iter().any(|w| canon.starts_with(w)) { Ok(canon) } else { anyhow::bail!("path '{}' is outside all workspace roots", rel) } }