//! File-drive tools (spec §7.4): write and list are workspace-internal; //! delete is a §15 gated category and always requires approval. use cm_domain::{FileDrive, FileNode}; use cm_llm::ToolDescriptor; use cm_tools::Effect; use serde_json::{json, Value}; use uuid::Uuid; use super::{Tool, ToolContext}; fn blob_key(ctx: &ToolContext, drive: FileDrive, path: &str) -> String { let scope = if drive.is_agent_scoped() { ctx.agent_id.to_string() } else { "shared".to_owned() }; format!("{}/{}/{}/{}", ctx.workspace_id, drive.as_str(), scope, path) } fn parse_drive(input: &Value) -> Result { match input["drive"].as_str() { None => Ok(FileDrive::Documents), Some(raw) => raw.parse(), } } fn parse_path(input: &Value) -> Result<&str, String> { let path = input["path"].as_str().ok_or("missing 'path'")?; if path.is_empty() || path.contains("..") || path.starts_with('/') { return Err(format!("invalid path: {path}")); } Ok(path) } /// Writes a file into one of the agent's drives. pub struct FilesWrite; #[async_trait::async_trait] impl Tool for FilesWrite { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor { name: "files.write".into(), description: "Writes a text file into a drive (documents by \ default, or the team's shared drive)." .into(), input_schema: json!({ "type": "object", "properties": { "path": {"type": "string"}, "content": {"type": "string"}, "drive": {"enum": ["documents", "shared"]}, }, "required": ["path", "content"], }), } } fn effects(&self) -> &'static [Effect] { &[Effect::WritesWorkspaceData] } async fn execute(&self, ctx: &ToolContext, input: Value) -> Result { let path = parse_path(&input)?; let drive = parse_drive(&input)?; let content = input["content"].as_str().ok_or("missing 'content'")?; let key = blob_key(ctx, drive, path); ctx.blob .put(&key, content.as_bytes()) .await .map_err(|e| e.to_string())?; cm_db::repo::files::upsert( &ctx.pool, &FileNode { id: Uuid::now_v7(), workspace_id: ctx.workspace_id, agent_id: drive.is_agent_scoped().then_some(ctx.agent_id), drive, path: path.to_owned(), size: content.len() as i64, blob_ref: key, }, ) .await .map_err(|e| e.to_string())?; Ok(json!({ "written": path, "size": content.len(), "drive": drive.as_str() })) } } /// Lists a drive's contents. pub struct FilesList; #[async_trait::async_trait] impl Tool for FilesList { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor { name: "files.list".into(), description: "Lists the files in a drive (documents, received, \ or shared)." .into(), input_schema: json!({ "type": "object", "properties": { "drive": {"enum": ["documents", "received", "shared"]}, }, }), } } fn effects(&self) -> &'static [Effect] { &[Effect::ReadsWorkspaceData] } async fn execute(&self, ctx: &ToolContext, input: Value) -> Result { let drive = parse_drive(&input)?; let nodes = cm_db::repo::files::list(&ctx.pool, ctx.workspace_id, drive, ctx.agent_id) .await .map_err(|e| e.to_string())?; let files: Vec = nodes .iter() .map(|n| json!({"path": n.path, "size": n.size})) .collect(); Ok(json!({ "drive": drive.as_str(), "files": files })) } } /// Deletes a file — §15 gated category (file deletion); always approved by /// a human before it runs. pub struct FilesDelete; #[async_trait::async_trait] impl Tool for FilesDelete { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor { name: "files.delete".into(), description: "Deletes a file from a drive. Requires human \ approval before it executes." .into(), input_schema: json!({ "type": "object", "properties": { "path": {"type": "string"}, "drive": {"enum": ["documents", "received", "shared"]}, }, "required": ["path"], }), } } fn effects(&self) -> &'static [Effect] { &[Effect::DeletesData] } fn preview(&self, input: &Value) -> Value { json!({ "summary": format!( "Delete file {}", input["path"].as_str().unwrap_or("(missing path)") ), "path": input["path"], "drive": input["drive"].as_str().unwrap_or("documents"), }) } async fn execute(&self, ctx: &ToolContext, input: Value) -> Result { let path = parse_path(&input)?; let drive = parse_drive(&input)?; let node = cm_db::repo::files::get(&ctx.pool, ctx.workspace_id, drive, ctx.agent_id, path) .await .map_err(|e| e.to_string())?; // Blob first; a missing blob is fine (row is the source of truth). match ctx.blob.delete(&node.blob_ref).await { Ok(()) | Err(cm_files::BlobError::NotFound) => {} Err(e) => return Err(e.to_string()), } cm_db::repo::files::delete(&ctx.pool, node.id) .await .map_err(|e| e.to_string())?; Ok(json!({ "deleted": path, "drive": drive.as_str() })) } }