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:
argenis de la rosa 2026-02-13 12:19:14 -05:00
commit 05cb353f7f
71 changed files with 15757 additions and 0 deletions

203
src/tools/file_read.rs Normal file
View file

@ -0,0 +1,203 @@
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<SecurityPolicy>,
}
impl FileReadTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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<ToolResult> {
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<SecurityPolicy> {
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;
}
}

242
src/tools/file_write.rs Normal file
View 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;
}
}

118
src/tools/memory_forget.rs Normal file
View file

@ -0,0 +1,118 @@
use super::traits::{Tool, ToolResult};
use crate::memory::Memory;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Let the agent forget/delete a memory entry
pub struct MemoryForgetTool {
memory: Arc<dyn Memory>,
}
impl MemoryForgetTool {
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
}
}
#[async_trait]
impl Tool for MemoryForgetTool {
fn name(&self) -> &str {
"memory_forget"
}
fn description(&self) -> &str {
"Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key of the memory to forget"
}
},
"required": ["key"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?;
match self.memory.forget(key).await {
Ok(true) => Ok(ToolResult {
success: true,
output: format!("Forgot memory: {key}"),
error: None,
}),
Ok(false) => Ok(ToolResult {
success: true,
output: format!("No memory found with key: {key}"),
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to forget memory: {e}")),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::{MemoryCategory, SqliteMemory};
use tempfile::TempDir;
fn test_mem() -> (TempDir, Arc<dyn Memory>) {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
(tmp, Arc::new(mem))
}
#[test]
fn name_and_schema() {
let (_tmp, mem) = test_mem();
let tool = MemoryForgetTool::new(mem);
assert_eq!(tool.name(), "memory_forget");
assert!(tool.parameters_schema()["properties"]["key"].is_object());
}
#[tokio::test]
async fn forget_existing() {
let (_tmp, mem) = test_mem();
mem.store("temp", "temporary", MemoryCategory::Conversation)
.await
.unwrap();
let tool = MemoryForgetTool::new(mem.clone());
let result = tool.execute(json!({"key": "temp"})).await.unwrap();
assert!(result.success);
assert!(result.output.contains("Forgot"));
assert!(mem.get("temp").await.unwrap().is_none());
}
#[tokio::test]
async fn forget_nonexistent() {
let (_tmp, mem) = test_mem();
let tool = MemoryForgetTool::new(mem);
let result = tool.execute(json!({"key": "nope"})).await.unwrap();
assert!(result.success);
assert!(result.output.contains("No memory found"));
}
#[tokio::test]
async fn forget_missing_key() {
let (_tmp, mem) = test_mem();
let tool = MemoryForgetTool::new(mem);
let result = tool.execute(json!({})).await;
assert!(result.is_err());
}
}

163
src/tools/memory_recall.rs Normal file
View file

@ -0,0 +1,163 @@
use super::traits::{Tool, ToolResult};
use crate::memory::Memory;
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write;
use std::sync::Arc;
/// Let the agent search its own memory
pub struct MemoryRecallTool {
memory: Arc<dyn Memory>,
}
impl MemoryRecallTool {
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
}
}
#[async_trait]
impl Tool for MemoryRecallTool {
fn name(&self) -> &str {
"memory_recall"
}
fn description(&self) -> &str {
"Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords or phrase to search for in memory"
},
"limit": {
"type": "integer",
"description": "Max results to return (default: 5)"
}
},
"required": ["query"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'query' parameter"))?;
#[allow(clippy::cast_possible_truncation)]
let limit = args
.get("limit")
.and_then(serde_json::Value::as_u64)
.map_or(5, |v| v as usize);
match self.memory.recall(query, limit).await {
Ok(entries) if entries.is_empty() => Ok(ToolResult {
success: true,
output: "No memories found matching that query.".into(),
error: None,
}),
Ok(entries) => {
let mut output = format!("Found {} memories:\n", entries.len());
for entry in &entries {
let score = entry.score.map_or_else(String::new, |s| format!(" [{s:.0}%]"));
let _ = writeln!(
output,
"- [{}] {}: {}{score}",
entry.category, entry.key, entry.content
);
}
Ok(ToolResult {
success: true,
output,
error: None,
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Memory recall failed: {e}")),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::{MemoryCategory, SqliteMemory};
use tempfile::TempDir;
fn seeded_mem() -> (TempDir, Arc<dyn Memory>) {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
(tmp, Arc::new(mem))
}
#[tokio::test]
async fn recall_empty() {
let (_tmp, mem) = seeded_mem();
let tool = MemoryRecallTool::new(mem);
let result = tool
.execute(json!({"query": "anything"}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("No memories found"));
}
#[tokio::test]
async fn recall_finds_match() {
let (_tmp, mem) = seeded_mem();
mem.store("lang", "User prefers Rust", MemoryCategory::Core)
.await
.unwrap();
mem.store("tz", "Timezone is EST", MemoryCategory::Core)
.await
.unwrap();
let tool = MemoryRecallTool::new(mem);
let result = tool.execute(json!({"query": "Rust"})).await.unwrap();
assert!(result.success);
assert!(result.output.contains("Rust"));
assert!(result.output.contains("Found 1"));
}
#[tokio::test]
async fn recall_respects_limit() {
let (_tmp, mem) = seeded_mem();
for i in 0..10 {
mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core)
.await
.unwrap();
}
let tool = MemoryRecallTool::new(mem);
let result = tool
.execute(json!({"query": "Rust", "limit": 3}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("Found 3"));
}
#[tokio::test]
async fn recall_missing_query() {
let (_tmp, mem) = seeded_mem();
let tool = MemoryRecallTool::new(mem);
let result = tool.execute(json!({})).await;
assert!(result.is_err());
}
#[test]
fn name_and_schema() {
let (_tmp, mem) = seeded_mem();
let tool = MemoryRecallTool::new(mem);
assert_eq!(tool.name(), "memory_recall");
assert!(tool.parameters_schema()["properties"]["query"].is_object());
}
}

146
src/tools/memory_store.rs Normal file
View file

@ -0,0 +1,146 @@
use super::traits::{Tool, ToolResult};
use crate::memory::{Memory, MemoryCategory};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Let the agent store memories — its own brain writes
pub struct MemoryStoreTool {
memory: Arc<dyn Memory>,
}
impl MemoryStoreTool {
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
}
}
#[async_trait]
impl Tool for MemoryStoreTool {
fn name(&self) -> &str {
"memory_store"
}
fn description(&self) -> &str {
"Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Unique key for this memory (e.g. 'user_lang', 'project_stack')"
},
"content": {
"type": "string",
"description": "The information to remember"
},
"category": {
"type": "string",
"enum": ["core", "daily", "conversation"],
"description": "Memory category: core (permanent), daily (session), conversation (chat)"
}
},
"required": ["key", "content"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
let category = match args.get("category").and_then(|v| v.as_str()) {
Some("daily") => MemoryCategory::Daily,
Some("conversation") => MemoryCategory::Conversation,
_ => MemoryCategory::Core,
};
match self.memory.store(key, content, category).await {
Ok(()) => Ok(ToolResult {
success: true,
output: format!("Stored memory: {key}"),
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to store memory: {e}")),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::SqliteMemory;
use tempfile::TempDir;
fn test_mem() -> (TempDir, Arc<dyn Memory>) {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
(tmp, Arc::new(mem))
}
#[test]
fn name_and_schema() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem);
assert_eq!(tool.name(), "memory_store");
let schema = tool.parameters_schema();
assert!(schema["properties"]["key"].is_object());
assert!(schema["properties"]["content"].is_object());
}
#[tokio::test]
async fn store_core() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem.clone());
let result = tool
.execute(json!({"key": "lang", "content": "Prefers Rust"}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("lang"));
let entry = mem.get("lang").await.unwrap();
assert!(entry.is_some());
assert_eq!(entry.unwrap().content, "Prefers Rust");
}
#[tokio::test]
async fn store_with_category() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem.clone());
let result = tool
.execute(json!({"key": "note", "content": "Fixed bug", "category": "daily"}))
.await
.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn store_missing_key() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem);
let result = tool.execute(json!({"content": "no key"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn store_missing_content() {
let (_tmp, mem) = test_mem();
let tool = MemoryStoreTool::new(mem);
let result = tool.execute(json!({"key": "no_content"})).await;
assert!(result.is_err());
}
}

189
src/tools/mod.rs Normal file
View file

@ -0,0 +1,189 @@
pub mod file_read;
pub mod file_write;
pub mod memory_forget;
pub mod memory_recall;
pub mod memory_store;
pub mod shell;
pub mod traits;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool;
pub use memory_store::MemoryStoreTool;
pub use shell::ShellTool;
pub use traits::Tool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
use crate::config::Config;
use crate::memory::Memory;
use crate::security::SecurityPolicy;
use anyhow::Result;
use std::sync::Arc;
/// Create the default tool registry
pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(security.clone())),
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security)),
]
}
/// Create full tool registry including memory tools
pub fn all_tools(
security: Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
) -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(security.clone())),
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security)),
Box::new(MemoryStoreTool::new(memory.clone())),
Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory)),
]
}
pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> {
let security = Arc::new(SecurityPolicy {
workspace_dir: config.workspace_dir.clone(),
..SecurityPolicy::default()
});
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?);
let tools_list = all_tools(security, mem);
match command {
super::ToolCommands::List => {
println!("Available tools ({}):", tools_list.len());
for tool in &tools_list {
println!(" - {}: {}", tool.name(), tool.description());
}
Ok(())
}
super::ToolCommands::Test { tool, args } => {
let matched = tools_list.iter().find(|t| t.name() == tool);
match matched {
Some(t) => {
let parsed: serde_json::Value = serde_json::from_str(&args)?;
let result = t.execute(parsed).await?;
println!("Success: {}", result.success);
println!("Output: {}", result.output);
if let Some(err) = result.error {
println!("Error: {err}");
}
Ok(())
}
None => anyhow::bail!("Unknown tool: {tool}"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_tools_has_three() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
assert_eq!(tools.len(), 3);
}
#[test]
fn default_tools_names() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"shell"));
assert!(names.contains(&"file_read"));
assert!(names.contains(&"file_write"));
}
#[test]
fn default_tools_all_have_descriptions() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
assert!(
!tool.description().is_empty(),
"Tool {} has empty description",
tool.name()
);
}
}
#[test]
fn default_tools_all_have_schemas() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
let schema = tool.parameters_schema();
assert!(
schema.is_object(),
"Tool {} schema is not an object",
tool.name()
);
assert!(
schema["properties"].is_object(),
"Tool {} schema has no properties",
tool.name()
);
}
}
#[test]
fn tool_spec_generation() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
let spec = tool.spec();
assert_eq!(spec.name, tool.name());
assert_eq!(spec.description, tool.description());
assert!(spec.parameters.is_object());
}
}
#[test]
fn tool_result_serde() {
let result = ToolResult {
success: true,
output: "hello".into(),
error: None,
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(parsed.success);
assert_eq!(parsed.output, "hello");
assert!(parsed.error.is_none());
}
#[test]
fn tool_result_with_error_serde() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("boom".into()),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(!parsed.success);
assert_eq!(parsed.error.as_deref(), Some("boom"));
}
#[test]
fn tool_spec_serde() {
let spec = ToolSpec {
name: "test".into(),
description: "A test tool".into(),
parameters: serde_json::json!({"type": "object"}),
};
let json = serde_json::to_string(&spec).unwrap();
let parsed: ToolSpec = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(parsed.description, "A test tool");
}
}

166
src/tools/shell.rs Normal file
View file

@ -0,0 +1,166 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Shell command execution tool with sandboxing
pub struct ShellTool {
security: Arc<SecurityPolicy>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>) -> Self {
Self { security }
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &str {
"shell"
}
fn description(&self) -> &str {
"Execute a shell command in the workspace directory"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
}
},
"required": ["command"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let command = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;
// Security check: validate command against allowlist
if !self.security.is_command_allowed(command) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Command not allowed by security policy: {command}")),
});
}
let output = tokio::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&self.security.workspace_dir)
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ToolResult {
success: output.status.success(),
output: stdout,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
})
}
#[test]
fn shell_tool_name() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
assert_eq!(tool.name(), "shell");
}
#[test]
fn shell_tool_description() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
assert!(!tool.description().is_empty());
}
#[test]
fn shell_tool_schema_has_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let schema = tool.parameters_schema();
assert!(schema["properties"]["command"].is_object());
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&json!("command")));
}
#[tokio::test]
async fn shell_executes_allowed_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let result = tool
.execute(json!({"command": "echo hello"}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.trim().contains("hello"));
assert!(result.error.is_none());
}
#[tokio::test]
async fn shell_blocks_disallowed_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let result = tool.execute(json!({"command": "rm -rf /"})).await.unwrap();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("not allowed"));
}
#[tokio::test]
async fn shell_blocks_readonly() {
let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly));
let result = tool.execute(json!({"command": "ls"})).await.unwrap();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("not allowed"));
}
#[tokio::test]
async fn shell_missing_command_param() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let result = tool.execute(json!({})).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("command"));
}
#[tokio::test]
async fn shell_wrong_type_param() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let result = tool.execute(json!({"command": 123})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn shell_captures_exit_code() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let result = tool
.execute(json!({"command": "ls /nonexistent_dir_xyz"}))
.await
.unwrap();
assert!(!result.success);
}
}

43
src/tools/traits.rs Normal file
View file

@ -0,0 +1,43 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// Result of a tool execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
}
/// Description of a tool for the LLM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
/// Core tool trait — implement for any capability
#[async_trait]
pub trait Tool: Send + Sync {
/// Tool name (used in LLM function calling)
fn name(&self) -> &str;
/// Human-readable description
fn description(&self) -> &str;
/// JSON schema for parameters
fn parameters_schema(&self) -> serde_json::Value;
/// Execute the tool with given arguments
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
/// Get the full spec for LLM registration
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
}
}
}