use super::traits::{Tool, ToolResult}; use crate::security::SecurityPolicy; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; /// Read file contents with path sandboxing pub struct FileReadTool { security: Arc, } impl FileReadTool { pub fn new(security: Arc) -> Self { Self { security } } } #[async_trait] impl Tool for FileReadTool { fn name(&self) -> &str { "file_read" } fn description(&self) -> &str { "Read the contents of a file in the workspace" } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "path": { "type": "string", "description": "Relative path to the file within the workspace" } }, "required": ["path"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let path = args .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; // Security check: validate path is within workspace if !self.security.is_path_allowed(path) { return Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Path not allowed by security policy: {path}")), }); } let full_path = self.security.workspace_dir.join(path); match tokio::fs::read_to_string(&full_path).await { Ok(contents) => Ok(ToolResult { success: true, output: contents, error: None, }), Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(format!("Failed to read file: {e}")), }), } } } #[cfg(test)] mod tests { use super::*; use crate::security::{AutonomyLevel, SecurityPolicy}; fn test_security(workspace: std::path::PathBuf) -> Arc { Arc::new(SecurityPolicy { autonomy: AutonomyLevel::Supervised, workspace_dir: workspace, ..SecurityPolicy::default() }) } #[test] fn file_read_name() { let tool = FileReadTool::new(test_security(std::env::temp_dir())); assert_eq!(tool.name(), "file_read"); } #[test] fn file_read_schema_has_path() { let tool = FileReadTool::new(test_security(std::env::temp_dir())); let schema = tool.parameters_schema(); assert!(schema["properties"]["path"].is_object()); assert!(schema["required"] .as_array() .unwrap() .contains(&json!("path"))); } #[tokio::test] async fn file_read_existing_file() { let dir = std::env::temp_dir().join("zeroclaw_test_file_read"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); tokio::fs::write(dir.join("test.txt"), "hello world") .await .unwrap(); let tool = FileReadTool::new(test_security(dir.clone())); let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); assert!(result.success); assert_eq!(result.output, "hello world"); assert!(result.error.is_none()); let _ = tokio::fs::remove_dir_all(&dir).await; } #[tokio::test] async fn file_read_nonexistent_file() { let dir = std::env::temp_dir().join("zeroclaw_test_file_read_missing"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); let tool = FileReadTool::new(test_security(dir.clone())); let result = tool.execute(json!({"path": "nope.txt"})).await.unwrap(); assert!(!result.success); assert!(result.error.as_ref().unwrap().contains("Failed to read")); let _ = tokio::fs::remove_dir_all(&dir).await; } #[tokio::test] async fn file_read_blocks_path_traversal() { let dir = std::env::temp_dir().join("zeroclaw_test_file_read_traversal"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); let tool = FileReadTool::new(test_security(dir.clone())); let result = tool .execute(json!({"path": "../../../etc/passwd"})) .await .unwrap(); assert!(!result.success); assert!(result.error.as_ref().unwrap().contains("not allowed")); let _ = tokio::fs::remove_dir_all(&dir).await; } #[tokio::test] async fn file_read_blocks_absolute_path() { let tool = FileReadTool::new(test_security(std::env::temp_dir())); let result = tool.execute(json!({"path": "/etc/passwd"})).await.unwrap(); assert!(!result.success); assert!(result.error.as_ref().unwrap().contains("not allowed")); } #[tokio::test] async fn file_read_missing_path_param() { let tool = FileReadTool::new(test_security(std::env::temp_dir())); let result = tool.execute(json!({})).await; assert!(result.is_err()); } #[tokio::test] async fn file_read_empty_file() { let dir = std::env::temp_dir().join("zeroclaw_test_file_read_empty"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); tokio::fs::write(dir.join("empty.txt"), "").await.unwrap(); let tool = FileReadTool::new(test_security(dir.clone())); let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap(); assert!(result.success); assert_eq!(result.output, ""); let _ = tokio::fs::remove_dir_all(&dir).await; } #[tokio::test] async fn file_read_nested_path() { let dir = std::env::temp_dir().join("zeroclaw_test_file_read_nested"); let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(dir.join("sub/dir")) .await .unwrap(); tokio::fs::write(dir.join("sub/dir/deep.txt"), "deep content") .await .unwrap(); let tool = FileReadTool::new(test_security(dir.clone())); let result = tool .execute(json!({"path": "sub/dir/deep.txt"})) .await .unwrap(); assert!(result.success); assert_eq!(result.output, "deep content"); let _ = tokio::fs::remove_dir_all(&dir).await; } }