Skip to main content

roboticus_agent/tools/
mod.rs

1mod cron;
2mod data;
3mod execution;
4mod filesystem;
5mod introspection;
6
7pub use cron::*;
8pub use data::*;
9pub use execution::*;
10pub use filesystem::*;
11pub use introspection::*;
12
13use std::collections::HashMap;
14use std::path::{Component, Path, PathBuf};
15
16use async_trait::async_trait;
17use serde_json::Value;
18
19use roboticus_core::config::{FilesystemSecurityConfig, SkillsConfig};
20use roboticus_core::{InputAuthority, RiskLevel};
21
22/// Filesystem / skill-script sandbox settings copied from config for tool execution.
23/// Exposed via [`get_runtime_context`](crate::tools::GetRuntimeContextTool) so the model
24/// can reason about boundaries instead of guessing.
25#[derive(Debug, Clone)]
26pub struct ToolSandboxSnapshot {
27    /// `security.filesystem.workspace_only` — relative file-tool paths stay under workspace.
28    pub filesystem_workspace_only: bool,
29    /// OS-level confinement for skill scripts (e.g. sandbox-exec / Landlock).
30    pub filesystem_script_fs_confinement: bool,
31    /// `security.filesystem.script_allowed_paths`
32    pub script_allowed_paths: Vec<PathBuf>,
33    pub skills_sandbox_env: bool,
34    pub skills_network_allowed: bool,
35    pub skills_dir: PathBuf,
36}
37
38impl ToolSandboxSnapshot {
39    pub fn from_config(fs: &FilesystemSecurityConfig, skills: &SkillsConfig) -> Self {
40        Self {
41            filesystem_workspace_only: fs.workspace_only,
42            filesystem_script_fs_confinement: fs.script_fs_confinement,
43            script_allowed_paths: fs.script_allowed_paths.clone(),
44            skills_sandbox_env: skills.sandbox_env,
45            skills_network_allowed: skills.network_allowed,
46            skills_dir: skills.skills_dir.clone(),
47        }
48    }
49}
50
51impl Default for ToolSandboxSnapshot {
52    fn default() -> Self {
53        Self {
54            filesystem_workspace_only: true,
55            filesystem_script_fs_confinement: true,
56            script_allowed_paths: Vec::new(),
57            skills_sandbox_env: true,
58            skills_network_allowed: false,
59            skills_dir: PathBuf::from("skills"),
60        }
61    }
62}
63
64pub(crate) const MAX_FILE_BYTES: usize = 1024 * 1024;
65pub(crate) const MAX_SEARCH_RESULTS: usize = 100;
66pub(crate) const MAX_WALK_FILES: usize = 5000;
67
68pub(crate) fn workspace_root_from_ctx(
69    ctx: &ToolContext,
70) -> std::result::Result<PathBuf, ToolError> {
71    std::fs::canonicalize(&ctx.workspace_root).map_err(|e| ToolError {
72        message: format!(
73            "failed to resolve workspace root '{}': {e}",
74            ctx.workspace_root.display()
75        ),
76    })
77}
78
79pub(crate) fn validate_rel_path(rel: &Path) -> std::result::Result<(), ToolError> {
80    if rel.is_absolute() {
81        return Err(ToolError {
82            message: "absolute paths are not allowed".into(),
83        });
84    }
85    if rel.components().any(|c| matches!(c, Component::ParentDir)) {
86        return Err(ToolError {
87            message: "path traversal ('..') is not allowed".into(),
88        });
89    }
90    Ok(())
91}
92
93pub(crate) fn normalize_workspace_rel_path(
94    root: &Path,
95    raw: &str,
96) -> std::result::Result<PathBuf, ToolError> {
97    if raw.is_empty() || raw == "." {
98        return Ok(PathBuf::from("."));
99    }
100
101    if raw == "~" || raw.starts_with("~/") {
102        let home = std::env::var("HOME").map_err(|_| ToolError {
103            message: "cannot expand '~' because HOME is not set".into(),
104        })?;
105        let suffix = if raw == "~" {
106            ""
107        } else {
108            raw.trim_start_matches("~/")
109        };
110        let expanded = Path::new(&home).join(suffix);
111        return absolutize_into_workspace_rel(root, &expanded);
112    }
113
114    let as_path = Path::new(raw);
115    if as_path.is_absolute() {
116        return absolutize_into_workspace_rel(root, as_path);
117    }
118
119    validate_rel_path(as_path)?;
120    Ok(as_path.to_path_buf())
121}
122
123pub(crate) fn absolutize_into_workspace_rel(
124    root: &Path,
125    absolute_or_expanded: &Path,
126) -> std::result::Result<PathBuf, ToolError> {
127    if let Ok(stripped) = absolute_or_expanded.strip_prefix(root) {
128        let rel = if stripped.as_os_str().is_empty() {
129            PathBuf::from(".")
130        } else {
131            stripped.to_path_buf()
132        };
133        validate_rel_path(&rel)?;
134        return Ok(rel);
135    }
136
137    if absolute_or_expanded.exists() {
138        let canonical = std::fs::canonicalize(absolute_or_expanded).map_err(|e| ToolError {
139            message: format!(
140                "failed to resolve '{}': {e}",
141                absolute_or_expanded.display()
142            ),
143        })?;
144        if let Ok(stripped) = canonical.strip_prefix(root) {
145            let rel = if stripped.as_os_str().is_empty() {
146                PathBuf::from(".")
147            } else {
148                stripped.to_path_buf()
149            };
150            validate_rel_path(&rel)?;
151            return Ok(rel);
152        }
153    }
154
155    Err(ToolError {
156        message: format!(
157            "path is outside workspace root: {}",
158            absolute_or_expanded.display()
159        ),
160    })
161}
162
163#[cfg(test)]
164pub(crate) fn resolve_workspace_path(
165    root: &Path,
166    rel: &str,
167    allow_nonexistent: bool,
168) -> std::result::Result<PathBuf, ToolError> {
169    resolve_workspace_path_with_allowed(root, rel, allow_nonexistent, &[])
170}
171
172/// Like `resolve_workspace_path` (test helper) but also accepts absolute paths that fall
173/// under one of the `tool_allowed_paths` entries. This lets tools like `bash`
174/// set `cwd` to configured external directories (code repos, vaults) without
175/// escaping the workspace sandbox for unconfigured paths.
176pub(crate) fn resolve_workspace_path_with_allowed(
177    root: &Path,
178    rel: &str,
179    allow_nonexistent: bool,
180    tool_allowed_paths: &[PathBuf],
181) -> std::result::Result<PathBuf, ToolError> {
182    // Check if this is an absolute path in tool_allowed_paths
183    let as_path = std::path::Path::new(rel);
184    if as_path.is_absolute() {
185        let is_allowed = tool_allowed_paths
186            .iter()
187            .any(|allowed| as_path.starts_with(allowed));
188        if is_allowed {
189            if as_path.exists() {
190                return std::fs::canonicalize(as_path).map_err(|e| ToolError {
191                    message: format!("failed to resolve '{}': {e}", as_path.display()),
192                });
193            } else if allow_nonexistent {
194                return Ok(as_path.to_path_buf());
195            } else {
196                return Err(ToolError {
197                    message: format!("path does not exist: {}", as_path.display()),
198                });
199            }
200        }
201    }
202
203    let rel_path = normalize_workspace_rel_path(root, rel)?;
204    let joined = root.join(rel_path);
205    if joined.exists() {
206        let canonical = std::fs::canonicalize(&joined).map_err(|e| ToolError {
207            message: format!("failed to resolve '{}': {e}", joined.display()),
208        })?;
209        if !canonical.starts_with(root) {
210            return Err(ToolError {
211                message: "resolved path escapes workspace root".into(),
212            });
213        }
214        return Ok(canonical);
215    }
216
217    if !allow_nonexistent {
218        return Err(ToolError {
219            message: format!("path does not exist: {}", joined.display()),
220        });
221    }
222
223    if let Some(parent) = joined.parent() {
224        let mut existing_ancestor = parent;
225        while !existing_ancestor.exists() {
226            existing_ancestor = existing_ancestor.parent().ok_or_else(|| ToolError {
227                message: "unable to resolve existing parent for target path".into(),
228            })?;
229        }
230        let canonical_parent = std::fs::canonicalize(existing_ancestor).map_err(|e| ToolError {
231            message: format!(
232                "failed to resolve parent '{}': {e}",
233                existing_ancestor.display()
234            ),
235        })?;
236        if !canonical_parent.starts_with(root) {
237            return Err(ToolError {
238                message: "target path escapes workspace root".into(),
239            });
240        }
241    }
242
243    Ok(joined)
244}
245
246pub(crate) fn walk_workspace_files(
247    base: &Path,
248    out: &mut Vec<PathBuf>,
249    count: &mut usize,
250) -> std::result::Result<(), ToolError> {
251    if *count >= MAX_WALK_FILES {
252        return Ok(());
253    }
254    let rd = std::fs::read_dir(base).map_err(|e| ToolError {
255        message: format!("failed to read directory '{}': {e}", base.display()),
256    })?;
257    for entry in rd {
258        if *count >= MAX_WALK_FILES {
259            break;
260        }
261        let entry = entry.map_err(|e| ToolError {
262            message: format!("failed to read directory entry: {e}"),
263        })?;
264        let name = entry.file_name();
265        let name = name.to_string_lossy();
266        let skip_dir = name.starts_with('.') || name == "node_modules";
267        let path = entry.path();
268        let ftype = entry.file_type().map_err(|e| ToolError {
269            message: format!("failed to inspect '{}': {e}", path.display()),
270        })?;
271        if ftype.is_symlink() {
272            continue;
273        }
274        if ftype.is_dir() {
275            if skip_dir {
276                continue;
277            }
278            walk_workspace_files(&path, out, count)?;
279        } else if ftype.is_file() {
280            out.push(path);
281            *count += 1;
282        }
283    }
284    Ok(())
285}
286
287pub(crate) fn wildcard_match_segment(pattern: &str, candidate: &str) -> bool {
288    let p: Vec<char> = pattern.chars().collect();
289    let s: Vec<char> = candidate.chars().collect();
290    let (mut pi, mut si) = (0usize, 0usize);
291    let (mut star, mut match_i) = (None::<usize>, 0usize);
292
293    while si < s.len() {
294        if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {
295            pi += 1;
296            si += 1;
297        } else if pi < p.len() && p[pi] == '*' {
298            star = Some(pi);
299            pi += 1;
300            match_i = si;
301        } else if let Some(star_idx) = star {
302            pi = star_idx + 1;
303            match_i += 1;
304            si = match_i;
305        } else {
306            return false;
307        }
308    }
309
310    while pi < p.len() && p[pi] == '*' {
311        pi += 1;
312    }
313
314    pi == p.len()
315}
316
317pub(crate) fn wildcard_match(pattern: &str, candidate: &str) -> bool {
318    fn rec(
319        p: &[&str],
320        c: &[&str],
321        pi: usize,
322        ci: usize,
323        memo: &mut std::collections::HashMap<(usize, usize), bool>,
324    ) -> bool {
325        if let Some(v) = memo.get(&(pi, ci)) {
326            return *v;
327        }
328
329        let out = if pi == p.len() {
330            ci == c.len()
331        } else if p[pi] == "**" {
332            // Collapse consecutive ** tokens.
333            let mut next_pi = pi + 1;
334            while next_pi < p.len() && p[next_pi] == "**" {
335                next_pi += 1;
336            }
337            if next_pi == p.len() {
338                true
339            } else {
340                (ci..=c.len()).any(|next_ci| rec(p, c, next_pi, next_ci, memo))
341            }
342        } else if ci < c.len() && wildcard_match_segment(p[pi], c[ci]) {
343            rec(p, c, pi + 1, ci + 1, memo)
344        } else {
345            false
346        };
347
348        memo.insert((pi, ci), out);
349        out
350    }
351
352    let pattern_norm = pattern.replace('\\', "/");
353    let candidate_norm = candidate.replace('\\', "/");
354    let p: Vec<&str> = pattern_norm.split('/').filter(|s| !s.is_empty()).collect();
355    let c: Vec<&str> = candidate_norm
356        .split('/')
357        .filter(|s| !s.is_empty())
358        .collect();
359    rec(&p, &c, 0, 0, &mut std::collections::HashMap::new())
360}
361
362#[async_trait]
363pub trait Tool: Send + Sync {
364    fn name(&self) -> &str;
365    fn description(&self) -> &str;
366    fn risk_level(&self) -> RiskLevel;
367    fn parameters_schema(&self) -> Value;
368
369    /// Optional companion skill for capability discovery (plugins may declare this in manifest).
370    fn paired_skill(&self) -> Option<&str> {
371        None
372    }
373
374    /// When set, this tool is bridged from a plugin with the given plugin id/name.
375    fn plugin_owner(&self) -> Option<&str> {
376        None
377    }
378
379    async fn execute(
380        &self,
381        params: Value,
382        _ctx: &ToolContext,
383    ) -> std::result::Result<ToolResult, ToolError>;
384}
385
386#[derive(Debug, Clone)]
387pub struct ToolContext {
388    pub session_id: String,
389    pub agent_id: String,
390    pub agent_name: String,
391    pub authority: InputAuthority,
392    pub workspace_root: PathBuf,
393    /// Absolute paths that tools may access even outside the workspace root.
394    /// Populated from `security.filesystem.tool_allowed_paths` in config.
395    /// This lets tools like `bash` set `cwd` to configured external directories
396    /// (e.g. code repos, Obsidian vaults) without escaping the workspace sandbox.
397    pub tool_allowed_paths: Vec<PathBuf>,
398    /// The channel through which the current message arrived (e.g. "api", "telegram", "discord").
399    /// `None` when channel is unknown or the tool was invoked outside a channel context.
400    pub channel: Option<String>,
401    /// Optional database handle for tools that need to query runtime state
402    /// (e.g. subagent status, task lists, delivery queue depth).
403    pub db: Option<roboticus_db::Database>,
404    /// Declarative sandbox boundaries from `roboticus.toml` at the time of invocation.
405    pub sandbox: ToolSandboxSnapshot,
406}
407
408#[derive(Debug, Clone)]
409pub struct ToolResult {
410    pub output: String,
411    pub metadata: Option<Value>,
412}
413
414#[derive(Debug, Clone, thiserror::Error)]
415#[error("ToolError: {message}")]
416pub struct ToolError {
417    pub message: String,
418}
419
420impl ToolError {
421    /// Convert into `RoboticusError::Tool` with an explicit tool name.
422    pub fn into_roboticus(self, tool: &str) -> roboticus_core::error::RoboticusError {
423        roboticus_core::error::RoboticusError::Tool {
424            tool: tool.to_owned(),
425            message: self.message,
426        }
427    }
428}
429
430pub struct ToolRegistry {
431    tools: HashMap<String, Box<dyn Tool>>,
432}
433
434impl ToolRegistry {
435    pub fn new() -> Self {
436        Self {
437            tools: HashMap::new(),
438        }
439    }
440
441    pub fn register(&mut self, tool: Box<dyn Tool>) {
442        self.tools.insert(tool.name().to_string(), tool);
443    }
444
445    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
446        self.tools.get(name).map(|t| t.as_ref())
447    }
448
449    pub fn list(&self) -> Vec<&dyn Tool> {
450        self.tools.values().map(|t| t.as_ref()).collect()
451    }
452}
453
454impl Default for ToolRegistry {
455    fn default() -> Self {
456        Self::new()
457    }
458}
459
460#[cfg(test)]
461#[path = "tests.rs"]
462mod tests;