mod cron;
mod data;
mod execution;
mod filesystem;
mod introspection;
pub use cron::*;
pub use data::*;
pub use execution::*;
pub use filesystem::*;
pub use introspection::*;
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use async_trait::async_trait;
use serde_json::Value;
use roboticus_core::config::{FilesystemSecurityConfig, SkillsConfig};
use roboticus_core::{InputAuthority, RiskLevel};
#[derive(Debug, Clone)]
pub struct ToolSandboxSnapshot {
pub filesystem_workspace_only: bool,
pub filesystem_script_fs_confinement: bool,
pub script_allowed_paths: Vec<PathBuf>,
pub skills_sandbox_env: bool,
pub skills_network_allowed: bool,
pub skills_dir: PathBuf,
}
impl ToolSandboxSnapshot {
pub fn from_config(fs: &FilesystemSecurityConfig, skills: &SkillsConfig) -> Self {
Self {
filesystem_workspace_only: fs.workspace_only,
filesystem_script_fs_confinement: fs.script_fs_confinement,
script_allowed_paths: fs.script_allowed_paths.clone(),
skills_sandbox_env: skills.sandbox_env,
skills_network_allowed: skills.network_allowed,
skills_dir: skills.skills_dir.clone(),
}
}
}
impl Default for ToolSandboxSnapshot {
fn default() -> Self {
Self {
filesystem_workspace_only: true,
filesystem_script_fs_confinement: true,
script_allowed_paths: Vec::new(),
skills_sandbox_env: true,
skills_network_allowed: false,
skills_dir: PathBuf::from("skills"),
}
}
}
pub(crate) const MAX_FILE_BYTES: usize = 1024 * 1024;
pub(crate) const MAX_SEARCH_RESULTS: usize = 100;
pub(crate) const MAX_WALK_FILES: usize = 5000;
pub(crate) fn workspace_root_from_ctx(
ctx: &ToolContext,
) -> std::result::Result<PathBuf, ToolError> {
std::fs::canonicalize(&ctx.workspace_root).map_err(|e| ToolError {
message: format!(
"failed to resolve workspace root '{}': {e}",
ctx.workspace_root.display()
),
})
}
pub(crate) fn validate_rel_path(rel: &Path) -> std::result::Result<(), ToolError> {
if rel.is_absolute() {
return Err(ToolError {
message: "absolute paths are not allowed".into(),
});
}
if rel.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(ToolError {
message: "path traversal ('..') is not allowed".into(),
});
}
Ok(())
}
pub(crate) fn normalize_workspace_rel_path(
root: &Path,
raw: &str,
) -> std::result::Result<PathBuf, ToolError> {
if raw.is_empty() || raw == "." {
return Ok(PathBuf::from("."));
}
if raw == "~" || raw.starts_with("~/") {
let home = std::env::var("HOME").map_err(|_| ToolError {
message: "cannot expand '~' because HOME is not set".into(),
})?;
let suffix = if raw == "~" {
""
} else {
raw.trim_start_matches("~/")
};
let expanded = Path::new(&home).join(suffix);
return absolutize_into_workspace_rel(root, &expanded);
}
let as_path = Path::new(raw);
if as_path.is_absolute() {
return absolutize_into_workspace_rel(root, as_path);
}
validate_rel_path(as_path)?;
Ok(as_path.to_path_buf())
}
pub(crate) fn absolutize_into_workspace_rel(
root: &Path,
absolute_or_expanded: &Path,
) -> std::result::Result<PathBuf, ToolError> {
if let Ok(stripped) = absolute_or_expanded.strip_prefix(root) {
let rel = if stripped.as_os_str().is_empty() {
PathBuf::from(".")
} else {
stripped.to_path_buf()
};
validate_rel_path(&rel)?;
return Ok(rel);
}
if absolute_or_expanded.exists() {
let canonical = std::fs::canonicalize(absolute_or_expanded).map_err(|e| ToolError {
message: format!(
"failed to resolve '{}': {e}",
absolute_or_expanded.display()
),
})?;
if let Ok(stripped) = canonical.strip_prefix(root) {
let rel = if stripped.as_os_str().is_empty() {
PathBuf::from(".")
} else {
stripped.to_path_buf()
};
validate_rel_path(&rel)?;
return Ok(rel);
}
}
Err(ToolError {
message: format!(
"path is outside workspace root: {}",
absolute_or_expanded.display()
),
})
}
#[cfg(test)]
pub(crate) fn resolve_workspace_path(
root: &Path,
rel: &str,
allow_nonexistent: bool,
) -> std::result::Result<PathBuf, ToolError> {
resolve_workspace_path_with_allowed(root, rel, allow_nonexistent, &[])
}
pub(crate) fn resolve_workspace_path_with_allowed(
root: &Path,
rel: &str,
allow_nonexistent: bool,
tool_allowed_paths: &[PathBuf],
) -> std::result::Result<PathBuf, ToolError> {
let as_path = std::path::Path::new(rel);
if as_path.is_absolute() {
let is_allowed = tool_allowed_paths
.iter()
.any(|allowed| as_path.starts_with(allowed));
if is_allowed {
if as_path.exists() {
return std::fs::canonicalize(as_path).map_err(|e| ToolError {
message: format!("failed to resolve '{}': {e}", as_path.display()),
});
} else if allow_nonexistent {
return Ok(as_path.to_path_buf());
} else {
return Err(ToolError {
message: format!("path does not exist: {}", as_path.display()),
});
}
}
}
let rel_path = normalize_workspace_rel_path(root, rel)?;
let joined = root.join(rel_path);
if joined.exists() {
let canonical = std::fs::canonicalize(&joined).map_err(|e| ToolError {
message: format!("failed to resolve '{}': {e}", joined.display()),
})?;
if !canonical.starts_with(root) {
return Err(ToolError {
message: "resolved path escapes workspace root".into(),
});
}
return Ok(canonical);
}
if !allow_nonexistent {
return Err(ToolError {
message: format!("path does not exist: {}", joined.display()),
});
}
if let Some(parent) = joined.parent() {
let mut existing_ancestor = parent;
while !existing_ancestor.exists() {
existing_ancestor = existing_ancestor.parent().ok_or_else(|| ToolError {
message: "unable to resolve existing parent for target path".into(),
})?;
}
let canonical_parent = std::fs::canonicalize(existing_ancestor).map_err(|e| ToolError {
message: format!(
"failed to resolve parent '{}': {e}",
existing_ancestor.display()
),
})?;
if !canonical_parent.starts_with(root) {
return Err(ToolError {
message: "target path escapes workspace root".into(),
});
}
}
Ok(joined)
}
pub(crate) fn walk_workspace_files(
base: &Path,
out: &mut Vec<PathBuf>,
count: &mut usize,
) -> std::result::Result<(), ToolError> {
if *count >= MAX_WALK_FILES {
return Ok(());
}
let rd = std::fs::read_dir(base).map_err(|e| ToolError {
message: format!("failed to read directory '{}': {e}", base.display()),
})?;
for entry in rd {
if *count >= MAX_WALK_FILES {
break;
}
let entry = entry.map_err(|e| ToolError {
message: format!("failed to read directory entry: {e}"),
})?;
let name = entry.file_name();
let name = name.to_string_lossy();
let skip_dir = name.starts_with('.') || name == "node_modules";
let path = entry.path();
let ftype = entry.file_type().map_err(|e| ToolError {
message: format!("failed to inspect '{}': {e}", path.display()),
})?;
if ftype.is_symlink() {
continue;
}
if ftype.is_dir() {
if skip_dir {
continue;
}
walk_workspace_files(&path, out, count)?;
} else if ftype.is_file() {
out.push(path);
*count += 1;
}
}
Ok(())
}
pub(crate) fn wildcard_match_segment(pattern: &str, candidate: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let s: Vec<char> = candidate.chars().collect();
let (mut pi, mut si) = (0usize, 0usize);
let (mut star, mut match_i) = (None::<usize>, 0usize);
while si < s.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
pi += 1;
match_i = si;
} else if let Some(star_idx) = star {
pi = star_idx + 1;
match_i += 1;
si = match_i;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub(crate) fn wildcard_match(pattern: &str, candidate: &str) -> bool {
fn rec(
p: &[&str],
c: &[&str],
pi: usize,
ci: usize,
memo: &mut std::collections::HashMap<(usize, usize), bool>,
) -> bool {
if let Some(v) = memo.get(&(pi, ci)) {
return *v;
}
let out = if pi == p.len() {
ci == c.len()
} else if p[pi] == "**" {
let mut next_pi = pi + 1;
while next_pi < p.len() && p[next_pi] == "**" {
next_pi += 1;
}
if next_pi == p.len() {
true
} else {
(ci..=c.len()).any(|next_ci| rec(p, c, next_pi, next_ci, memo))
}
} else if ci < c.len() && wildcard_match_segment(p[pi], c[ci]) {
rec(p, c, pi + 1, ci + 1, memo)
} else {
false
};
memo.insert((pi, ci), out);
out
}
let pattern_norm = pattern.replace('\\', "/");
let candidate_norm = candidate.replace('\\', "/");
let p: Vec<&str> = pattern_norm.split('/').filter(|s| !s.is_empty()).collect();
let c: Vec<&str> = candidate_norm
.split('/')
.filter(|s| !s.is_empty())
.collect();
rec(&p, &c, 0, 0, &mut std::collections::HashMap::new())
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn risk_level(&self) -> RiskLevel;
fn parameters_schema(&self) -> Value;
fn paired_skill(&self) -> Option<&str> {
None
}
fn plugin_owner(&self) -> Option<&str> {
None
}
async fn execute(
&self,
params: Value,
_ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError>;
}
#[derive(Debug, Clone)]
pub struct ToolContext {
pub session_id: String,
pub agent_id: String,
pub agent_name: String,
pub authority: InputAuthority,
pub workspace_root: PathBuf,
pub tool_allowed_paths: Vec<PathBuf>,
pub channel: Option<String>,
pub db: Option<roboticus_db::Database>,
pub sandbox: ToolSandboxSnapshot,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub output: String,
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("ToolError: {message}")]
pub struct ToolError {
pub message: String,
}
impl ToolError {
pub fn into_roboticus(self, tool: &str) -> roboticus_core::error::RoboticusError {
roboticus_core::error::RoboticusError::Tool {
tool: tool.to_owned(),
message: self.message,
}
}
}
pub struct ToolRegistry {
tools: HashMap<String, Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn register(&mut self, tool: Box<dyn Tool>) {
self.tools.insert(tool.name().to_string(), tool);
}
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools.get(name).map(|t| t.as_ref())
}
pub fn list(&self) -> Vec<&dyn Tool> {
self.tools.values().map(|t| t.as_ref()).collect()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;