1use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use uuid::Uuid;
10use chrono::Utc;
11
12use super::{Tool, ToolContext, ToolResult, ToolError};
13use super::permission::{PermissionRequest, RiskLevel, create_permission_request};
14
15pub struct WriteTool;
17
18#[derive(Debug, Deserialize)]
19struct WriteParams {
20 #[serde(rename = "filePath")]
21 file_path: String,
22 content: String,
23 #[serde(default)]
24 create_backup: Option<bool>,
25 #[serde(default)]
26 force_overwrite: Option<bool>,
27}
28
29#[async_trait]
30impl Tool for WriteTool {
31 fn id(&self) -> &str {
32 "write"
33 }
34
35 fn description(&self) -> &str {
36 "Write content to a file with atomic operations and backup support"
37 }
38
39 fn parameters_schema(&self) -> Value {
40 json!({
41 "type": "object",
42 "properties": {
43 "filePath": {
44 "type": "string",
45 "description": "The absolute path to the file to write (must be absolute, not relative)"
46 },
47 "content": {
48 "type": "string",
49 "description": "The content to write to the file"
50 },
51 "createBackup": {
52 "type": "boolean",
53 "description": "Create a backup of existing file before overwriting",
54 "default": true
55 },
56 "forceOverwrite": {
57 "type": "boolean",
58 "description": "Force overwrite without confirmation for existing files",
59 "default": false
60 }
61 },
62 "required": ["filePath", "content"]
63 })
64 }
65
66 async fn execute(
67 &self,
68 args: Value,
69 ctx: ToolContext,
70 ) -> Result<ToolResult, ToolError> {
71 let params: WriteParams = serde_json::from_value(args)
72 .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
73
74 let path = if PathBuf::from(¶ms.file_path).is_absolute() {
76 PathBuf::from(¶ms.file_path)
77 } else {
78 return Err(ToolError::InvalidParameters(
79 "filePath must be absolute, not relative".to_string()
80 ));
81 };
82
83 let file_exists = path.exists();
85 let is_new_file = !file_exists;
86
87 self.validate_write_operation(&path, ¶ms, &ctx).await?;
89
90 let risk_level = if is_new_file {
92 RiskLevel::Low
93 } else {
94 RiskLevel::Medium
95 };
96
97 let permission_request = create_permission_request(
98 Uuid::new_v4().to_string(),
99 ctx.session_id.clone(),
100 if is_new_file {
101 format!("Create new file: {}", path.display())
102 } else {
103 format!("Overwrite existing file: {}", path.display())
104 },
105 risk_level,
106 json!({
107 "filePath": path.to_string_lossy(),
108 "content": params.content.chars().take(200).collect::<String>(),
109 "contentLength": params.content.len(),
110 "isNewFile": is_new_file,
111 "createBackup": params.create_backup.unwrap_or(true)
112 }),
113 );
114
115 let backup_path = if file_exists && params.create_backup.unwrap_or(true) {
120 Some(self.create_backup(&path).await?)
121 } else {
122 None
123 };
124
125 if let Some(parent) = path.parent() {
127 fs::create_dir_all(parent).await.map_err(|e| {
128 ToolError::ExecutionFailed(format!("Failed to create parent directories: {}", e))
129 })?;
130 }
131
132 let write_result = self.atomic_write(&path, ¶ms.content).await;
134
135 match write_result {
136 Ok(()) => {
137 let line_count = params.content.lines().count();
141 let byte_count = params.content.len();
142
143 let relative_path = path
145 .strip_prefix(&ctx.working_directory)
146 .unwrap_or(&path)
147 .to_string_lossy()
148 .to_string();
149
150 let metadata = json!({
152 "path": path.to_string_lossy(),
153 "relative_path": relative_path,
154 "lines_written": line_count,
155 "bytes_written": byte_count,
156 "was_new_file": is_new_file,
157 "backup_created": backup_path.is_some(),
158 "backup_path": backup_path.as_ref().map(|p| p.to_string_lossy().to_string()),
159 "timestamp": Utc::now().to_rfc3339(),
160 "content_preview": params.content.lines().take(3).collect::<Vec<_>>().join("\n")
161 });
162
163 let action = if is_new_file { "Created" } else { "Updated" };
164
165 Ok(ToolResult {
166 title: relative_path,
167 metadata,
168 output: format!(
169 "{} file with {} bytes ({} lines){}.",
170 action,
171 byte_count,
172 line_count,
173 if backup_path.is_some() { " (backup created)" } else { "" }
174 ),
175 })
176 }
177 Err(e) => {
178 if let Some(backup) = &backup_path {
180 if let Err(restore_err) = fs::rename(backup, &path).await {
181 tracing::warn!("Failed to restore backup after write failure: {}", restore_err);
182 }
183 }
184 Err(e)
185 }
186 }
187 }
188}
189
190impl WriteTool {
191 async fn validate_write_operation(
193 &self,
194 path: &Path,
195 params: &WriteParams,
196 ctx: &ToolContext,
197 ) -> Result<(), ToolError> {
198 if !self.is_path_allowed(path, &ctx.working_directory) {
200 return Err(ToolError::PermissionDenied(format!(
201 "Writing outside of working directory is not allowed: {}",
202 path.display()
203 )));
204 }
205
206 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
208 match ext.to_lowercase().as_str() {
209 "exe" | "bat" | "cmd" | "com" | "scr" | "msi" => {
210 return Err(ToolError::PermissionDenied(
211 "Writing executable files is not allowed for security reasons".to_string()
212 ));
213 }
214 _ => {}
215 }
216 }
217
218 if params.content.len() > 50_000_000 { return Err(ToolError::InvalidParameters(
221 "Content too large (>50MB). Consider breaking into smaller files.".to_string()
222 ));
223 }
224
225 if !params.content.is_ascii() && params.content.chars().any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t') {
227 return Err(ToolError::InvalidParameters(
228 "Content contains invalid control characters".to_string()
229 ));
230 }
231
232 Ok(())
233 }
234
235 fn is_path_allowed(&self, target_path: &Path, working_dir: &Path) -> bool {
237 target_path.starts_with(working_dir) || target_path == working_dir
239 }
240
241 async fn create_backup(&self, original_path: &Path) -> Result<PathBuf, ToolError> {
243 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
244 let backup_name = format!(
245 "{}.backup.{}",
246 original_path.file_name()
247 .and_then(|n| n.to_str())
248 .unwrap_or("unknown"),
249 timestamp
250 );
251
252 let backup_path = original_path.parent()
253 .unwrap_or(Path::new("."))
254 .join(backup_name);
255
256 fs::copy(original_path, &backup_path).await.map_err(|e| {
257 ToolError::ExecutionFailed(format!("Failed to create backup: {}", e))
258 })?;
259
260 Ok(backup_path)
261 }
262
263 async fn atomic_write(&self, target_path: &Path, content: &str) -> Result<(), ToolError> {
265 let temp_name = format!(
267 ".{}.tmp.{}",
268 target_path.file_name()
269 .and_then(|n| n.to_str())
270 .unwrap_or("unknown"),
271 Uuid::new_v4().simple()
272 );
273
274 let temp_path = target_path.parent()
275 .unwrap_or(Path::new("."))
276 .join(temp_name);
277
278 fs::write(&temp_path, content).await.map_err(|e| {
280 ToolError::ExecutionFailed(format!("Failed to write temporary file: {}", e))
281 })?;
282
283 fs::rename(&temp_path, target_path).await.map_err(|e| {
285 if let Err(cleanup_err) = std::fs::remove_file(&temp_path) {
287 tracing::warn!("Failed to clean up temporary file {}: {}", temp_path.display(), cleanup_err);
288 }
289 ToolError::ExecutionFailed(format!("Failed to move temporary file to target: {}", e))
290 })?;
291
292 Ok(())
293 }
294}