use std::path::{Component, Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::features::Features;
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolCapability {
ReadOnly,
WritesFiles,
ExecutesCode,
Network,
Sandboxable,
RequiresApproval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApprovalRequirement {
#[default]
Auto,
Suggest,
Required,
}
#[derive(Debug, Clone, Error)]
pub enum ToolError {
#[error("Failed to validate input: {message}")]
InvalidInput { message: String },
#[error("Failed to validate input: missing required field '{field}'")]
MissingField { field: String },
#[error("Failed to resolve path '{path}': path escapes workspace")]
PathEscape { path: PathBuf },
#[error("Failed to execute tool: {message}")]
ExecutionFailed { message: String },
#[error("Failed to execute tool: operation timed out after {seconds}s")]
Timeout { seconds: u64 },
#[error("Failed to locate tool: {message}")]
NotAvailable { message: String },
#[error("Failed to authorize tool execution: {message}")]
PermissionDenied { message: String },
}
impl ToolError {
#[must_use]
pub fn invalid_input(msg: impl Into<String>) -> Self {
Self::InvalidInput {
message: msg.into(),
}
}
#[must_use]
pub fn missing_field(field: impl Into<String>) -> Self {
Self::MissingField {
field: field.into(),
}
}
#[must_use]
pub fn execution_failed(msg: impl Into<String>) -> Self {
Self::ExecutionFailed {
message: msg.into(),
}
}
#[must_use]
#[allow(dead_code)]
pub fn path_escape(path: impl Into<PathBuf>) -> Self {
Self::PathEscape { path: path.into() }
}
#[must_use]
pub fn not_available(msg: impl Into<String>) -> Self {
Self::NotAvailable {
message: msg.into(),
}
}
#[must_use]
pub fn permission_denied(msg: impl Into<String>) -> Self {
Self::PermissionDenied {
message: msg.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl ToolResult {
#[must_use]
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
success: true,
metadata: None,
}
}
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
content: message.into(),
success: false,
metadata: None,
}
}
pub fn json<T: Serialize>(value: &T) -> Result<Self, serde_json::Error> {
Ok(Self {
content: serde_json::to_string_pretty(value)?,
success: true,
metadata: None,
})
}
#[must_use]
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Default)]
pub enum SandboxPolicy {
#[default]
None,
}
#[derive(Clone)]
pub struct ToolContext {
pub workspace: PathBuf,
pub shell_manager: SharedShellManager,
pub trust_mode: bool,
#[allow(dead_code)]
pub sandbox_policy: SandboxPolicy,
pub notes_path: PathBuf,
#[allow(dead_code)]
pub mcp_config_path: PathBuf,
pub elevated_sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
pub auto_approve: bool,
pub features: Features,
pub state_namespace: String,
}
impl ToolContext {
#[must_use]
pub fn new(workspace: impl Into<PathBuf>) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
let notes_path = workspace.join(".deepseek").join("notes.md");
let mcp_config_path = workspace.join(".deepseek").join("mcp.json");
Self {
workspace,
shell_manager,
trust_mode: false,
sandbox_policy: SandboxPolicy::None,
notes_path,
mcp_config_path,
elevated_sandbox_policy: None,
auto_approve: false,
features: Features::with_defaults(),
state_namespace: "workspace".to_string(),
}
}
#[allow(dead_code)]
pub fn with_options(
workspace: impl Into<PathBuf>,
trust_mode: bool,
notes_path: impl Into<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
Self {
workspace,
shell_manager,
trust_mode,
sandbox_policy: SandboxPolicy::None,
notes_path: notes_path.into(),
mcp_config_path: mcp_config_path.into(),
elevated_sandbox_policy: None,
auto_approve: false,
features: Features::with_defaults(),
state_namespace: "workspace".to_string(),
}
}
pub fn with_auto_approve(
workspace: impl Into<PathBuf>,
trust_mode: bool,
notes_path: impl Into<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
auto_approve: bool,
) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
Self {
workspace,
shell_manager,
trust_mode,
sandbox_policy: SandboxPolicy::None,
notes_path: notes_path.into(),
mcp_config_path: mcp_config_path.into(),
elevated_sandbox_policy: None,
auto_approve,
features: Features::with_defaults(),
state_namespace: "workspace".to_string(),
}
}
pub fn resolve_path(&self, raw: &str) -> Result<PathBuf, ToolError> {
let candidate = if std::path::Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
self.workspace.join(raw)
};
if self.trust_mode {
return Ok(candidate.canonicalize().unwrap_or(candidate));
}
let workspace_canonical = self
.workspace
.canonicalize()
.unwrap_or_else(|_| self.workspace.clone());
let candidate_canonical = candidate
.canonicalize()
.unwrap_or_else(|_| normalize_path(&candidate));
let workspace_normalized = normalize_path(&workspace_canonical);
if !candidate_canonical.starts_with(&workspace_normalized) {
let workspace_plain = normalize_path(&self.workspace);
let candidate_normalized = normalize_path(&candidate);
if !candidate_normalized.starts_with(&workspace_plain) {
return Err(ToolError::PathEscape {
path: candidate_canonical,
});
}
}
if candidate.exists() {
let canonical = candidate.canonicalize().map_err(|e| {
ToolError::execution_failed(format!(
"Failed to canonicalize {}: {}",
candidate.display(),
e
))
})?;
if !canonical.starts_with(&workspace_canonical) {
return Err(ToolError::PathEscape { path: canonical });
}
return Ok(canonical);
}
let mut existing_ancestor = candidate.clone();
let mut suffix_parts: Vec<std::ffi::OsString> = Vec::new();
while !existing_ancestor.exists() {
if let Some(file_name) = existing_ancestor.file_name() {
suffix_parts.push(file_name.to_owned());
}
match existing_ancestor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
existing_ancestor = parent.to_path_buf();
}
_ => {
break;
}
}
}
let canonical_ancestor = if existing_ancestor.exists() {
existing_ancestor
.canonicalize()
.unwrap_or(existing_ancestor)
} else {
existing_ancestor
};
let mut canonical = canonical_ancestor;
for part in suffix_parts.into_iter().rev() {
canonical.push(part);
}
let canonical = normalize_path(&canonical);
if !canonical.starts_with(&workspace_canonical)
&& !canonical.starts_with(&workspace_normalized)
{
return Err(ToolError::PathEscape { path: canonical });
}
Ok(canonical)
}
#[allow(dead_code)]
pub fn with_trust_mode(mut self, trust: bool) -> Self {
self.trust_mode = trust;
self
}
#[allow(dead_code)]
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
self.sandbox_policy = policy;
self
}
pub fn with_features(mut self, features: Features) -> Self {
self.features = features;
self
}
pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
self.shell_manager = shell_manager;
self
}
pub fn with_elevated_sandbox_policy(mut self, policy: crate::sandbox::SandboxPolicy) -> Self {
self.elevated_sandbox_policy = Some(policy);
self
}
pub fn with_state_namespace(mut self, namespace: impl Into<String>) -> Self {
self.state_namespace = namespace.into();
self
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut prefix: Option<std::ffi::OsString> = None;
let mut is_root = false;
let mut stack: Vec<std::ffi::OsString> = Vec::new();
for component in path.components() {
match component {
Component::Prefix(prefix_component) => {
prefix = Some(prefix_component.as_os_str().to_owned());
}
Component::RootDir => {
is_root = true;
}
Component::CurDir => {}
Component::ParentDir => {
let parent = Component::ParentDir.as_os_str();
if let Some(last) = stack.pop() {
if last == parent {
stack.push(last);
stack.push(parent.to_owned());
}
} else if !is_root {
stack.push(parent.to_owned());
}
}
Component::Normal(part) => {
stack.push(part.to_owned());
}
}
}
let mut normalized = PathBuf::new();
if let Some(prefix) = prefix {
normalized.push(prefix);
}
if is_root {
normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR));
}
for part in stack {
normalized.push(part);
}
normalized
}
#[async_trait]
pub trait ToolSpec: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
fn capabilities(&self) -> Vec<ToolCapability>;
fn approval_requirement(&self) -> ApprovalRequirement {
let caps = self.capabilities();
if caps.contains(&ToolCapability::ExecutesCode) {
ApprovalRequirement::Required
} else if caps.contains(&ToolCapability::WritesFiles) {
ApprovalRequirement::Suggest
} else {
ApprovalRequirement::Auto
}
}
#[allow(dead_code)]
fn is_sandboxable(&self) -> bool {
self.capabilities().contains(&ToolCapability::Sandboxable)
}
fn is_read_only(&self) -> bool {
let caps = self.capabilities();
caps.contains(&ToolCapability::ReadOnly)
&& !caps.contains(&ToolCapability::WritesFiles)
&& !caps.contains(&ToolCapability::ExecutesCode)
}
fn supports_parallel(&self) -> bool {
false
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError>;
}
pub fn required_str<'a>(input: &'a Value, field: &str) -> Result<&'a str, ToolError> {
input
.get(field)
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::missing_field(field))
}
pub fn optional_str<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
input.get(field).and_then(|v| v.as_str())
}
#[allow(dead_code)]
pub fn required_u64(input: &Value, field: &str) -> Result<u64, ToolError> {
input
.get(field)
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| ToolError::missing_field(field))
}
pub fn optional_u64(input: &Value, field: &str, default: u64) -> u64 {
input
.get(field)
.and_then(serde_json::Value::as_u64)
.unwrap_or(default)
}
pub fn optional_bool(input: &Value, field: &str, default: bool) -> bool {
input
.get(field)
.and_then(serde_json::Value::as_bool)
.unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn test_tool_result_success() {
let result = ToolResult::success("hello");
assert!(result.success);
assert_eq!(result.content, "hello");
assert!(result.metadata.is_none());
}
#[test]
fn test_tool_result_error() {
let result = ToolResult::error("something failed");
assert!(!result.success);
assert_eq!(result.content, "something failed");
}
#[test]
fn test_tool_result_json() {
let data = json!({"key": "value"});
let result = ToolResult::json(&data).unwrap();
assert!(result.success);
assert!(result.content.contains("key"));
}
#[test]
fn test_tool_result_with_metadata() {
let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
assert!(result.metadata.is_some());
}
#[test]
fn test_tool_context_resolve_path_relative() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let test_file = tmp.path().join("test.txt");
std::fs::write(&test_file, "test").expect("write");
let resolved = ctx.resolve_path("test.txt").expect("resolve");
assert!(resolved.ends_with("test.txt"));
}
#[test]
fn test_tool_context_resolve_path_escape() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("/etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_parent_traversal() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("../escape.txt");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_normalizes_parent() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("new/../safe.txt");
assert!(result.is_ok());
}
#[test]
fn test_tool_context_trust_mode() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
let result = ctx.resolve_path("/tmp");
assert!(result.is_ok());
}
#[test]
fn test_required_str() {
let input = json!({"name": "test", "count": 42});
assert_eq!(required_str(&input, "name").unwrap(), "test");
assert!(required_str(&input, "missing").is_err());
assert!(required_str(&input, "count").is_err()); }
#[test]
fn test_optional_str() {
let input = json!({"name": "test"});
assert_eq!(optional_str(&input, "name"), Some("test"));
assert_eq!(optional_str(&input, "missing"), None);
}
#[test]
fn test_required_u64() {
let input = json!({"count": 42});
assert_eq!(required_u64(&input, "count").unwrap(), 42);
assert!(required_u64(&input, "missing").is_err());
}
#[test]
fn test_optional_u64() {
let input = json!({"count": 42});
assert_eq!(optional_u64(&input, "count", 0), 42);
assert_eq!(optional_u64(&input, "missing", 100), 100);
}
#[test]
fn test_optional_bool() {
let input = json!({"flag": true});
assert!(optional_bool(&input, "flag", false));
assert!(!optional_bool(&input, "missing", false));
}
#[test]
fn test_tool_error_display() {
let err = ToolError::missing_field("path");
assert_eq!(
format!("{err}"),
"Failed to validate input: missing required field 'path'"
);
let err = ToolError::execution_failed("boom");
assert_eq!(format!("{err}"), "Failed to execute tool: boom");
}
#[test]
fn test_approval_requirement_default() {
let level = ApprovalRequirement::default();
assert_eq!(level, ApprovalRequirement::Auto);
}
}