feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
242
src/tools/file_write.rs
Normal file
242
src/tools/file_write.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Write file contents with path sandboxing
|
||||
pub struct FileWriteTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl FileWriteTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"file_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Write contents to 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"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
|
||||
|
||||
let content = args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'content' 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);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = full_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
match tokio::fs::write(&full_path, content).await {
|
||||
Ok(()) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Written {} bytes to {path}", content.len()),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to write file: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
|
||||
fn test_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: workspace,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_name() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
assert_eq!(tool.name(), "file_write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_schema_has_path_and_content() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["path"].is_object());
|
||||
assert!(schema["properties"]["content"].is_object());
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.contains(&json!("path")));
|
||||
assert!(required.contains(&json!("content")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_creates_file() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "out.txt", "content": "written!"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("8 bytes"));
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("out.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "written!");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_creates_parent_dirs() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "deep");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_overwrites_existing() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("exist.txt"), "old")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "exist.txt", "content": "new"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("exist.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "new");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_path_traversal() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "../../etc/evil", "content": "bad"}))
|
||||
.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_write_blocks_absolute_path() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "/etc/evil", "content": "bad"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_missing_path_param() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({"content": "data"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_missing_content_param() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({"path": "file.txt"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_empty_content() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "empty.txt", "content": ""}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("0 bytes"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue