//! Built-in tools. Each tool declares a JSON schema, whether it is read-only //! (allowed in plan mode) and how to run it. pub mod agent; pub mod bash; pub mod craft; pub mod cad; pub mod fs; pub mod web; use crate::app::App; use crate::provider::ToolDef; use crate::types::ImageData; use anyhow::{Result, bail}; use async_trait::async_trait; use serde_json::Value; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use tokio_util::sync::CancellationToken; #[derive(Debug, Default)] pub struct ToolOutput { pub content: String, pub images: Vec, pub meta: Option, pub is_error: bool, } impl ToolOutput { pub fn text(s: impl Into) -> Self { Self { content: s.into(), ..Default::default() } } pub fn error(s: impl Into) -> Self { Self { content: s.into(), is_error: true, ..Default::default() } } pub fn with_meta(mut self, meta: Value) -> Self { self.meta = Some(meta); self } } #[derive(Clone)] pub struct ToolCtx { pub app: Arc, pub session_id: String, pub call_id: String, pub cwd: PathBuf, pub cancel: CancellationToken, /// Agent mode running this tool (subagents inherit restrictions). pub agent: String, /// Directories this session may access (the owner's roots). pub roots: Vec, /// Shell sandbox of the session owner (None = run unsandboxed). pub sandbox: Option, /// Craft clean room active for this session (no page sources, no binaries). pub clean_room: bool, /// User the call runs for when there is no session (CLI requests). pub owner_id: Option, } impl ToolCtx { /// Resolve a user/model supplied path against the session cwd and make sure /// it stays inside the workspace. pub fn resolve(&self, p: &str) -> Result { let p = p.trim(); let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else { self.cwd.join(p) }; let norm = normalize(&raw); if norm.starts_with("/tmp") || self.roots.iter().any(|r| norm.starts_with(normalize(r))) { // Symlinks must not lead out of the allowed directories either. if let Ok(real) = std::fs::canonicalize(&norm) { if !real.starts_with("/tmp") && !self.roots.iter().any(|r| real.starts_with(r)) { bail!("path {} resolves outside your directories", norm.display()); } } return Ok(norm); } let allowed: Vec = self.roots.iter().map(|r| r.display().to_string()).collect(); bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", ")) } /// Like `resolve`, but also allows the read-only built-in resources (skill templates). pub fn resolve_read(&self, p: &str) -> Result { let builtin = normalize(&crate::prompts::builtin_resources()); let raw = if Path::new(p.trim()).is_absolute() { PathBuf::from(p.trim()) } else { self.cwd.join(p.trim()) }; let norm = normalize(&raw); if norm.starts_with(&builtin) { return Ok(norm); } self.resolve(p) } /// The root directory containing the working directory. pub fn root(&self) -> PathBuf { let mut roots = self.roots.clone(); roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len())); roots.iter().find(|r| self.cwd.starts_with(r)).cloned().unwrap_or_else(|| self.cwd.clone()) } pub fn progress(&self, output: impl Into) { self.app.emit(crate::types::Event::ToolProgress { session_id: self.session_id.clone(), call_id: self.call_id.clone(), output: output.into(), }); } } /// Lexical normalization (no symlink resolution, works for non-existing files). pub fn normalize(p: &Path) -> PathBuf { let mut out = PathBuf::new(); for c in p.components() { match c { Component::ParentDir => { out.pop(); } Component::CurDir => {} other => out.push(other.as_os_str()), } } out } #[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> String; fn schema(&self) -> Value; /// Read-only tools are available in plan mode and never modify state. fn read_only(&self) -> bool { false } /// Category used by the "accept_edits" permission mode. fn is_edit(&self) -> bool { false } /// Short human title for permission prompts. fn title(&self, input: &Value) -> String { let _ = input; self.name().to_string() } /// Optional unified diff shown in permission prompts. async fn preview(&self, _input: &Value, _ctx: &ToolCtx) -> Option { None } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result; fn def(&self) -> ToolDef { ToolDef { name: self.name().into(), description: self.description(), input_schema: self.schema() } } } pub fn builtin_tools() -> Vec> { vec![ Arc::new(fs::Read), Arc::new(fs::Write), Arc::new(fs::Edit), Arc::new(fs::List), Arc::new(fs::Glob), Arc::new(fs::Grep), Arc::new(bash::Bash), Arc::new(crate::shells::BashOutput), Arc::new(crate::shells::KillShell), Arc::new(web::WebFetch), Arc::new(web::WebSearch), Arc::new(agent::CraftPhase), Arc::new(agent::ChangeDirectory), Arc::new(agent::Remember), Arc::new(cad::OpenScad), Arc::new(crate::jobs::Schedule), Arc::new(crate::lsp::Diagnostics), Arc::new(agent::TodoWrite), Arc::new(agent::AskUser), Arc::new(agent::Task), Arc::new(craft::BrowserSnapshot), Arc::new(craft::ImageCompare), Arc::new(crate::prompts::SkillTool), Arc::new(crate::images::GenerateImage), Arc::new(crate::reverse::BinaryInfo), Arc::new(crate::reverse::Decompile), ] } /// Helper for reading typed arguments. pub fn arg_str<'a>(input: &'a Value, key: &str) -> Result<&'a str> { input .get(key) .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("missing required string argument '{key}'")) } pub fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut cut = max; while !s.is_char_boundary(cut) { cut -= 1; } format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len()) } /// Server tools answer the permission rules' questions about themselves. impl codecraft_core::permission::ToolInfo for dyn Tool { fn name(&self) -> &str { Tool::name(self) } fn read_only(&self) -> bool { Tool::read_only(self) } fn is_edit(&self) -> bool { Tool::is_edit(self) } }