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#[derive(Debug, Clone)]
26pub struct ToolSandboxSnapshot {
27 pub filesystem_workspace_only: bool,
29 pub filesystem_script_fs_confinement: bool,
31 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
172pub(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 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 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 fn paired_skill(&self) -> Option<&str> {
371 None
372 }
373
374 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 pub tool_allowed_paths: Vec<PathBuf>,
398 pub channel: Option<String>,
401 pub db: Option<roboticus_db::Database>,
404 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 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;