1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use zeroclaw_api::tool::{Tool, ToolResult};
5use zeroclaw_config::policy::SecurityPolicy;
6
7pub struct FileWriteTool {
9 security: Arc<SecurityPolicy>,
10}
11
12impl FileWriteTool {
13 pub fn new(security: Arc<SecurityPolicy>) -> Self {
14 Self { security }
15 }
16}
17
18#[async_trait]
19impl Tool for FileWriteTool {
20 fn name(&self) -> &str {
21 "file_write"
22 }
23
24 fn description(&self) -> &str {
25 "Write contents to a file in the workspace"
26 }
27
28 fn parameters_schema(&self) -> serde_json::Value {
29 json!({
30 "type": "object",
31 "properties": {
32 "path": {
33 "type": "string",
34 "description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
35 },
36 "content": {
37 "type": "string",
38 "description": "Content to write to the file"
39 }
40 },
41 "required": ["path", "content"]
42 })
43 }
44
45 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
46 let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
47 ::zeroclaw_log::record!(
48 WARN,
49 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
50 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
51 .with_attrs(::serde_json::json!({"param": "path"})),
52 "file_write: missing path parameter"
53 );
54 anyhow::Error::msg("Missing 'path' parameter")
55 })?;
56
57 let content = args
58 .get("content")
59 .and_then(|v| v.as_str())
60 .ok_or_else(|| {
61 ::zeroclaw_log::record!(
62 WARN,
63 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
64 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
65 .with_attrs(::serde_json::json!({"param": "content"})),
66 "file_write: missing content parameter"
67 );
68 anyhow::Error::msg("Missing 'content' parameter")
69 })?;
70
71 if !self.security.can_act() {
72 return Ok(ToolResult {
73 success: false,
74 output: String::new(),
75 error: Some("Action blocked: autonomy is read-only".into()),
76 });
77 }
78
79 let full_path = self.security.resolve_tool_path(path);
84
85 let Some(parent) = full_path.parent() else {
86 return Ok(ToolResult {
87 success: false,
88 output: String::new(),
89 error: Some("Invalid path: missing parent directory".into()),
90 });
91 };
92
93 tokio::fs::create_dir_all(parent).await?;
95
96 let resolved_parent = match tokio::fs::canonicalize(parent).await {
98 Ok(p) => p,
99 Err(e) => {
100 return Ok(ToolResult {
101 success: false,
102 output: String::new(),
103 error: Some(format!("Failed to resolve file path: {e}")),
104 });
105 }
106 };
107
108 if !self.security.is_resolved_path_allowed(&resolved_parent) {
109 return Ok(ToolResult {
110 success: false,
111 output: String::new(),
112 error: Some(
113 self.security
114 .resolved_path_violation_message(&resolved_parent),
115 ),
116 });
117 }
118
119 let Some(file_name) = full_path.file_name() else {
120 return Ok(ToolResult {
121 success: false,
122 output: String::new(),
123 error: Some("Invalid path: missing file name".into()),
124 });
125 };
126
127 let resolved_target = resolved_parent.join(file_name);
128
129 if self.security.is_runtime_config_path(&resolved_target) {
130 return Ok(ToolResult {
131 success: false,
132 output: String::new(),
133 error: Some(
134 self.security
135 .runtime_config_violation_message(&resolved_target),
136 ),
137 });
138 }
139
140 if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
142 && meta.file_type().is_symlink()
143 {
144 return Ok(ToolResult {
145 success: false,
146 output: String::new(),
147 error: Some(format!(
148 "Refusing to write through symlink: {}",
149 resolved_target.display()
150 )),
151 });
152 }
153
154 match tokio::fs::write(&resolved_target, content).await {
155 Ok(()) => Ok(ToolResult {
156 success: true,
157 output: format!("Written {} bytes to {path}", content.len()),
158 error: None,
159 }),
160 Err(e) => Ok(ToolResult {
161 success: false,
162 output: String::new(),
163 error: Some(format!("Failed to write file: {e}")),
164 }),
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::wrappers::{PathGuardedTool, RateLimitedTool};
173 use zeroclaw_config::autonomy::AutonomyLevel;
174 use zeroclaw_config::policy::SecurityPolicy;
175
176 fn test_tool(workspace: std::path::PathBuf) -> FileWriteTool {
177 let security = Arc::new(SecurityPolicy {
178 autonomy: AutonomyLevel::Supervised,
179 workspace_dir: workspace,
180 ..SecurityPolicy::default()
181 });
182 FileWriteTool::new(security)
183 }
184
185 fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
189 let security = Arc::new(SecurityPolicy {
190 autonomy: AutonomyLevel::Supervised,
191 workspace_dir: workspace,
192 ..SecurityPolicy::default()
193 });
194 Box::new(RateLimitedTool::new(
195 PathGuardedTool::new(FileWriteTool::new(security.clone()), security.clone()),
196 security,
197 ))
198 }
199
200 fn test_tool_with(
201 workspace: std::path::PathBuf,
202 autonomy: AutonomyLevel,
203 max_actions_per_hour: u32,
204 ) -> FileWriteTool {
205 let security = Arc::new(SecurityPolicy {
206 autonomy,
207 workspace_dir: workspace,
208 max_actions_per_hour,
209 ..SecurityPolicy::default()
210 });
211 FileWriteTool::new(security)
212 }
213
214 #[test]
215 fn file_write_name() {
216 let tool = test_tool(std::env::temp_dir());
217 assert_eq!(tool.name(), "file_write");
218 }
219
220 #[test]
221 fn file_write_schema_has_path_and_content() {
222 let tool = test_tool(std::env::temp_dir());
223 let schema = tool.parameters_schema();
224 assert!(schema["properties"]["path"].is_object());
225 assert!(schema["properties"]["content"].is_object());
226 let required = schema["required"].as_array().unwrap();
227 assert!(required.contains(&json!("path")));
228 assert!(required.contains(&json!("content")));
229 }
230
231 #[tokio::test]
232 async fn file_write_creates_file() {
233 let dir = std::env::temp_dir().join("zeroclaw_test_file_write");
234 let _ = tokio::fs::remove_dir_all(&dir).await;
235 tokio::fs::create_dir_all(&dir).await.unwrap();
236
237 let tool = test_tool(dir.clone());
238 let result = tool
239 .execute(json!({"path": "out.txt", "content": "written!"}))
240 .await
241 .unwrap();
242 assert!(result.success);
243 assert!(result.output.contains("8 bytes"));
244
245 let content = tokio::fs::read_to_string(dir.join("out.txt"))
246 .await
247 .unwrap();
248 assert_eq!(content, "written!");
249
250 let _ = tokio::fs::remove_dir_all(&dir).await;
251 }
252
253 #[tokio::test]
254 async fn file_write_creates_parent_dirs() {
255 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested");
256 let _ = tokio::fs::remove_dir_all(&dir).await;
257 tokio::fs::create_dir_all(&dir).await.unwrap();
258
259 let tool = test_tool(dir.clone());
260 let result = tool
261 .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
262 .await
263 .unwrap();
264 assert!(result.success);
265
266 let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
267 .await
268 .unwrap();
269 assert_eq!(content, "deep");
270
271 let _ = tokio::fs::remove_dir_all(&dir).await;
272 }
273
274 #[tokio::test]
275 async fn file_write_normalizes_workspace_prefixed_relative_path() {
276 let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed");
277 let workspace = root.join("workspace");
278 let _ = tokio::fs::remove_dir_all(&root).await;
279 tokio::fs::create_dir_all(&workspace).await.unwrap();
280
281 let tool = test_tool(workspace.clone());
282 let workspace_prefixed = workspace
283 .strip_prefix(std::path::Path::new("/"))
284 .unwrap()
285 .join("nested/out.txt");
286 let result = tool
287 .execute(json!({
288 "path": workspace_prefixed.to_string_lossy(),
289 "content": "written!"
290 }))
291 .await
292 .unwrap();
293 assert!(result.success);
294
295 let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
296 .await
297 .unwrap();
298 assert_eq!(content, "written!");
299 assert!(!workspace.join(workspace_prefixed).exists());
300
301 let _ = tokio::fs::remove_dir_all(&root).await;
302 }
303
304 #[tokio::test]
305 async fn file_write_overwrites_existing() {
306 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
307 let _ = tokio::fs::remove_dir_all(&dir).await;
308 tokio::fs::create_dir_all(&dir).await.unwrap();
309 tokio::fs::write(dir.join("exist.txt"), "old")
310 .await
311 .unwrap();
312
313 let tool = test_tool(dir.clone());
314 let result = tool
315 .execute(json!({"path": "exist.txt", "content": "new"}))
316 .await
317 .unwrap();
318 assert!(result.success);
319
320 let content = tokio::fs::read_to_string(dir.join("exist.txt"))
321 .await
322 .unwrap();
323 assert_eq!(content, "new");
324
325 let _ = tokio::fs::remove_dir_all(&dir).await;
326 }
327
328 #[tokio::test]
329 async fn file_write_blocks_path_traversal() {
330 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal");
331 let _ = tokio::fs::remove_dir_all(&dir).await;
332 tokio::fs::create_dir_all(&dir).await.unwrap();
333
334 let tool = wrapped_tool(dir.clone());
335 let result = tool
336 .execute(json!({"path": "../../etc/evil", "content": "bad"}))
337 .await
338 .unwrap();
339 assert!(!result.success);
340 assert!(
341 result.error.as_ref().unwrap().contains("Path blocked"),
342 "expected 'Path blocked' error, got: {:?}",
343 result.error
344 );
345
346 let _ = tokio::fs::remove_dir_all(&dir).await;
347 }
348
349 #[tokio::test]
350 async fn file_write_blocks_absolute_path() {
351 let tool = wrapped_tool(std::env::temp_dir());
352 let result = tool
353 .execute(json!({"path": "/etc/evil", "content": "bad"}))
354 .await
355 .unwrap();
356 assert!(!result.success);
357 assert!(
358 result.error.as_ref().unwrap().contains("Path blocked"),
359 "expected 'Path blocked' error, got: {:?}",
360 result.error
361 );
362 }
363
364 #[tokio::test]
365 async fn file_write_missing_path_param() {
366 let tool = test_tool(std::env::temp_dir());
367 let result = tool.execute(json!({"content": "data"})).await;
368 assert!(result.is_err());
369 }
370
371 #[tokio::test]
372 async fn file_write_missing_content_param() {
373 let tool = test_tool(std::env::temp_dir());
374 let result = tool.execute(json!({"path": "file.txt"})).await;
375 assert!(result.is_err());
376 }
377
378 #[tokio::test]
379 async fn file_write_empty_content() {
380 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty");
381 let _ = tokio::fs::remove_dir_all(&dir).await;
382 tokio::fs::create_dir_all(&dir).await.unwrap();
383
384 let tool = test_tool(dir.clone());
385 let result = tool
386 .execute(json!({"path": "empty.txt", "content": ""}))
387 .await
388 .unwrap();
389 assert!(result.success);
390 assert!(result.output.contains("0 bytes"));
391
392 let _ = tokio::fs::remove_dir_all(&dir).await;
393 }
394
395 #[cfg(unix)]
396 #[tokio::test]
397 async fn file_write_blocks_symlink_escape() {
398 use std::os::unix::fs::symlink;
399
400 let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_escape");
401 let workspace = root.join("workspace");
402 let outside = root.join("outside");
403
404 let _ = tokio::fs::remove_dir_all(&root).await;
405 tokio::fs::create_dir_all(&workspace).await.unwrap();
406 tokio::fs::create_dir_all(&outside).await.unwrap();
407
408 symlink(&outside, workspace.join("escape_dir")).unwrap();
409
410 let tool = test_tool(workspace.clone());
411 let result = tool
412 .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"}))
413 .await
414 .unwrap();
415
416 assert!(!result.success);
417 assert!(
418 result
419 .error
420 .as_deref()
421 .unwrap_or("")
422 .contains("escapes workspace")
423 );
424 assert!(!outside.join("hijack.txt").exists());
425
426 let _ = tokio::fs::remove_dir_all(&root).await;
427 }
428
429 #[tokio::test]
430 async fn file_write_blocks_readonly_mode() {
431 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_readonly");
432 let _ = tokio::fs::remove_dir_all(&dir).await;
433 tokio::fs::create_dir_all(&dir).await.unwrap();
434
435 let tool = test_tool_with(dir.clone(), AutonomyLevel::ReadOnly, 20);
436 let result = tool
437 .execute(json!({"path": "out.txt", "content": "should-block"}))
438 .await
439 .unwrap();
440
441 assert!(!result.success);
442 assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
443 assert!(!dir.join("out.txt").exists());
444
445 let _ = tokio::fs::remove_dir_all(&dir).await;
446 }
447
448 #[cfg(unix)]
449 #[tokio::test]
450 async fn file_write_blocks_symlink_target_file() {
451 use std::os::unix::fs::symlink;
452
453 let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_target");
454 let workspace = root.join("workspace");
455 let outside = root.join("outside");
456
457 let _ = tokio::fs::remove_dir_all(&root).await;
458 tokio::fs::create_dir_all(&workspace).await.unwrap();
459 tokio::fs::create_dir_all(&outside).await.unwrap();
460
461 tokio::fs::write(outside.join("target.txt"), "original")
462 .await
463 .unwrap();
464 symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
465
466 let tool = test_tool(workspace.clone());
467 let result = tool
468 .execute(json!({"path": "linked.txt", "content": "overwritten"}))
469 .await
470 .unwrap();
471
472 assert!(!result.success, "writing through symlink must be blocked");
473 assert!(
474 result.error.as_deref().unwrap_or("").contains("symlink"),
475 "error should mention symlink"
476 );
477
478 let content = tokio::fs::read_to_string(outside.join("target.txt"))
479 .await
480 .unwrap();
481 assert_eq!(content, "original", "original file must not be modified");
482
483 let _ = tokio::fs::remove_dir_all(&root).await;
484 }
485
486 #[tokio::test]
487 async fn file_write_absolute_path_in_workspace() {
488 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_abs_path");
489 let _ = tokio::fs::remove_dir_all(&dir).await;
490 tokio::fs::create_dir_all(&dir).await.unwrap();
491
492 let dir = tokio::fs::canonicalize(&dir).await.unwrap();
494
495 let tool = test_tool(dir.clone());
496
497 let abs_path = dir.join("abs_test.txt");
498 let result = tool
499 .execute(
500 json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
501 )
502 .await
503 .unwrap();
504
505 assert!(
506 result.success,
507 "writing via absolute workspace path should succeed, error: {:?}",
508 result.error
509 );
510
511 let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
512 .await
513 .unwrap();
514 assert_eq!(content, "absolute!");
515
516 let _ = tokio::fs::remove_dir_all(&dir).await;
517 }
518
519 #[tokio::test]
520 async fn file_write_blocks_null_byte_in_path() {
521 let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
522 let _ = tokio::fs::remove_dir_all(&dir).await;
523 tokio::fs::create_dir_all(&dir).await.unwrap();
524
525 let tool = test_tool(dir.clone());
526 let result = tool
527 .execute(json!({"path": "file\u{0000}.txt", "content": "bad"}))
528 .await
529 .unwrap();
530 assert!(!result.success, "paths with null bytes must be blocked");
531
532 let _ = tokio::fs::remove_dir_all(&dir).await;
533 }
534
535 #[tokio::test]
536 async fn file_write_blocks_path_outside_workspace() {
537 let root = std::env::temp_dir().join("zeroclaw_test_file_write_outside_workspace");
538 let workspace = root.join("workspace");
539 let outside_file = root.join("outside.txt");
540 let _ = tokio::fs::remove_dir_all(&root).await;
541 tokio::fs::create_dir_all(&workspace).await.unwrap();
542
543 let tool = test_tool(workspace.clone());
544 let result = tool
545 .execute(json!({
546 "path": outside_file.to_string_lossy(),
547 "content": "should-block"
548 }))
549 .await
550 .unwrap();
551
552 assert!(!result.success);
553 assert!(!outside_file.exists());
554
555 let _ = tokio::fs::remove_dir_all(&root).await;
556 }
557}