use std::path::{Component, Path, PathBuf};
use bob_core::{
error::ToolError,
types::{ToolCall, ToolDescriptor, ToolResult},
};
use serde_json::json;
#[derive(Debug, Clone)]
pub struct BuiltinToolPort {
workspace: PathBuf,
}
impl BuiltinToolPort {
#[must_use]
pub fn new(workspace: PathBuf) -> Self {
Self { workspace }
}
fn resolve_safe_path(&self, relative: &str) -> Result<PathBuf, ToolError> {
let path = Path::new(relative);
if path.is_absolute() {
return Err(ToolError::Execution("absolute paths not allowed in sandbox".to_string()));
}
for component in path.components() {
if matches!(component, Component::ParentDir | Component::Prefix(_)) {
return Err(ToolError::Execution(
"parent directory traversal (..) not allowed".to_string(),
));
}
}
Ok(self.workspace.join(relative))
}
async fn workspace_canonical_path(&self) -> Result<PathBuf, ToolError> {
tokio::fs::canonicalize(&self.workspace).await.map_err(|err| {
ToolError::Execution(format!(
"failed to resolve workspace path '{}': {err}",
self.workspace.display()
))
})
}
async fn nearest_existing_ancestor(path: &Path) -> Result<PathBuf, ToolError> {
let mut probe = path.to_path_buf();
loop {
match tokio::fs::symlink_metadata(&probe).await {
Ok(_) => return Ok(probe),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = probe.parent() else {
return Err(ToolError::Execution(format!(
"path '{}' has no existing ancestor",
path.display()
)));
};
probe = parent.to_path_buf();
}
Err(err) => {
return Err(ToolError::Execution(format!(
"failed to inspect path '{}': {err}",
probe.display()
)));
}
}
}
}
async fn ensure_within_workspace(&self, candidate: &Path) -> Result<(), ToolError> {
let workspace = self.workspace_canonical_path().await?;
let ancestor = Self::nearest_existing_ancestor(candidate).await?;
let canonical_ancestor = tokio::fs::canonicalize(&ancestor).await.map_err(|err| {
ToolError::Execution(format!("failed to resolve path '{}': {err}", ancestor.display()))
})?;
if !canonical_ancestor.starts_with(&workspace) {
return Err(ToolError::Execution(format!(
"path '{}' resolves outside workspace sandbox",
candidate.display()
)));
}
Ok(())
}
async fn file_read(&self, args: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
let path_str = args
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::Execution("missing 'path' argument".to_string()))?;
let full_path = self.resolve_safe_path(path_str)?;
self.ensure_within_workspace(&full_path).await?;
let content = tokio::fs::read_to_string(&full_path)
.await
.map_err(|e| ToolError::Execution(format!("read failed: {e}")))?;
Ok(json!({ "content": content, "path": path_str }))
}
async fn file_write(&self, args: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
let path_str = args
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::Execution("missing 'path' argument".to_string()))?;
let content = args
.get("content")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::Execution("missing 'content' argument".to_string()))?;
let full_path = self.resolve_safe_path(path_str)?;
self.ensure_within_workspace(&full_path).await?;
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| ToolError::Execution(format!("mkdir failed: {e}")))?;
}
tokio::fs::write(&full_path, content)
.await
.map_err(|e| ToolError::Execution(format!("write failed: {e}")))?;
Ok(json!({ "written": true, "path": path_str, "bytes": content.len() }))
}
async fn file_list(&self, args: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
let path_str = args.get("path").and_then(serde_json::Value::as_str).unwrap_or(".");
let full_path = self.resolve_safe_path(path_str)?;
self.ensure_within_workspace(&full_path).await?;
let mut entries = Vec::new();
let mut dir = tokio::fs::read_dir(&full_path)
.await
.map_err(|e| ToolError::Execution(format!("read_dir failed: {e}")))?;
loop {
match dir.next_entry().await {
Ok(Some(entry)) => {
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = entry.file_type().await.is_ok_and(|ft| ft.is_dir());
entries.push(json!({
"name": name,
"is_dir": is_dir,
}));
}
Ok(None) => break,
Err(e) => {
return Err(ToolError::Execution(format!("readdir entry failed: {e}")));
}
}
}
Ok(json!({ "path": path_str, "entries": entries }))
}
async fn shell_exec(&self, args: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
let command = args
.get("command")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::Execution("missing 'command' argument".to_string()))?;
let timeout_ms =
args.get("timeout_ms").and_then(serde_json::Value::as_u64).unwrap_or(15_000);
let output = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
tokio::process::Command::new("/bin/sh")
.arg("-c")
.arg(command)
.current_dir(&self.workspace)
.output(),
)
.await
.map_err(|_| ToolError::Timeout { name: "local/shell_exec".to_string() })?
.map_err(|e| ToolError::Execution(format!("exec failed: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
Ok(json!({
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
}))
}
}
#[async_trait::async_trait]
impl bob_core::ports::ToolPort for BuiltinToolPort {
async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
Ok(vec![
ToolDescriptor::new("local/file_read", "Read file contents from the workspace")
.with_input_schema(json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path within workspace" }
},
"required": ["path"]
})),
ToolDescriptor::new("local/file_write", "Write content to a file in the workspace")
.with_input_schema(json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path within workspace" },
"content": { "type": "string", "description": "File content to write" }
},
"required": ["path", "content"]
})),
ToolDescriptor::new("local/file_list", "List directory contents in the workspace")
.with_input_schema(json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative directory path (default: '.')" }
}
})),
ToolDescriptor::new("local/shell_exec", "Execute a shell command in the workspace directory")
.with_input_schema(json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to execute" },
"timeout_ms": { "type": "integer", "description": "Timeout in milliseconds (default: 15000)" }
},
"required": ["command"]
})),
])
}
async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
let result = match call.name.as_str() {
"local/file_read" => self.file_read(&call.arguments).await,
"local/file_write" => self.file_write(&call.arguments).await,
"local/file_list" => self.file_list(&call.arguments).await,
"local/shell_exec" => self.shell_exec(&call.arguments).await,
_ => return Err(ToolError::NotFound { name: call.name }),
};
match result {
Ok(output) => Ok(ToolResult { name: call.name, output, is_error: false }),
Err(e) => Ok(ToolResult {
name: call.name,
output: json!({ "error": e.to_string() }),
is_error: true,
}),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs as unix_fs;
use bob_core::ports::ToolPort;
use super::*;
#[tokio::test]
async fn list_tools_returns_four_builtins() {
let port = BuiltinToolPort::new(PathBuf::from("/tmp"));
let tools = port.list_tools().await;
assert!(tools.is_ok());
let tools = tools.unwrap_or_default();
assert_eq!(tools.len(), 4);
assert!(tools.iter().all(|t| t.id.starts_with("local/")));
}
#[test]
fn resolve_safe_path_rejects_absolute() {
let port = BuiltinToolPort::new(PathBuf::from("/workspace"));
assert!(port.resolve_safe_path("/etc/passwd").is_err());
}
#[test]
fn resolve_safe_path_rejects_traversal() {
let port = BuiltinToolPort::new(PathBuf::from("/workspace"));
assert!(port.resolve_safe_path("../etc/passwd").is_err());
assert!(port.resolve_safe_path("foo/../../etc/passwd").is_err());
}
#[test]
fn resolve_safe_path_allows_relative() {
let port = BuiltinToolPort::new(PathBuf::from("/workspace"));
let result = port.resolve_safe_path("src/main.rs");
assert!(result.is_ok());
assert_eq!(result.unwrap_or_default(), PathBuf::from("/workspace/src/main.rs"));
}
#[tokio::test]
async fn file_read_on_temp_file() {
let dir = tempfile::tempdir().unwrap_or_else(|_| {
tempfile::TempDir::new().unwrap_or_else(|_| unreachable!())
});
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "hello").unwrap_or_default();
let port = BuiltinToolPort::new(dir.path().to_path_buf());
let result =
port.call_tool(ToolCall::new("local/file_read", json!({ "path": "test.txt" }))).await;
assert!(result.is_ok());
if let Ok(r) = result {
assert!(!r.is_error);
assert_eq!(r.output.get("content").and_then(|v| v.as_str()), Some("hello"));
}
}
#[cfg(unix)]
#[tokio::test]
async fn file_read_rejects_symlink_escape() {
let workspace = tempfile::tempdir().unwrap_or_else(|_| unreachable!());
let outside = tempfile::tempdir().unwrap_or_else(|_| unreachable!());
let outside_file = outside.path().join("secret.txt");
let wrote = std::fs::write(&outside_file, "secret");
assert!(wrote.is_ok());
let link_path = workspace.path().join("link.txt");
let linked = unix_fs::symlink(&outside_file, &link_path);
assert!(linked.is_ok(), "should create symlink test fixture");
let port = BuiltinToolPort::new(workspace.path().to_path_buf());
let result =
port.call_tool(ToolCall::new("local/file_read", json!({ "path": "link.txt" }))).await;
assert!(result.is_ok());
if let Ok(tool_result) = result {
assert!(tool_result.is_error, "symlink escape must be blocked");
let error = tool_result
.output
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
assert!(error.contains("outside workspace"));
}
}
#[cfg(unix)]
#[tokio::test]
async fn file_write_rejects_symlink_escape() {
let workspace = tempfile::tempdir().unwrap_or_else(|_| unreachable!());
let outside = tempfile::tempdir().unwrap_or_else(|_| unreachable!());
let outside_dir = outside.path().join("target");
let created = std::fs::create_dir_all(&outside_dir);
assert!(created.is_ok());
let link_path = workspace.path().join("escape");
let linked = unix_fs::symlink(&outside_dir, &link_path);
assert!(linked.is_ok(), "should create symlink test fixture");
let port = BuiltinToolPort::new(workspace.path().to_path_buf());
let result = port
.call_tool(ToolCall::new(
"local/file_write",
json!({
"path": "escape/pwned.txt",
"content": "blocked"
}),
))
.await;
assert!(result.is_ok());
if let Ok(tool_result) = result {
assert!(tool_result.is_error, "symlink escape must be blocked");
}
assert!(
!outside_dir.join("pwned.txt").exists(),
"write must not touch paths outside workspace"
);
}
}