1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
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
47 .get("path")
48 .and_then(|v| v.as_str())
49 .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
50
51 let content = args
52 .get("content")
53 .and_then(|v| v.as_str())
54 .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
55
56 if !self.security.can_act() {
57 return Ok(ToolResult {
58 success: false,
59 output: String::new(),
60 error: Some("Action blocked: autonomy is read-only".into()),
61 });
62 }
63
64 if self.security.is_rate_limited() {
65 return Ok(ToolResult {
66 success: false,
67 output: String::new(),
68 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
69 });
70 }
71
72 if !self.security.is_path_allowed(path) {
74 return Ok(ToolResult {
75 success: false,
76 output: String::new(),
77 error: Some(format!("Path not allowed by security policy: {path}")),
78 });
79 }
80
81 let full_path = self.security.resolve_tool_path(path);
82
83 let Some(parent) = full_path.parent() else {
84 return Ok(ToolResult {
85 success: false,
86 output: String::new(),
87 error: Some("Invalid path: missing parent directory".into()),
88 });
89 };
90
91 tokio::fs::create_dir_all(parent).await?;
93
94 let resolved_parent = match tokio::fs::canonicalize(parent).await {
96 Ok(p) => p,
97 Err(e) => {
98 return Ok(ToolResult {
99 success: false,
100 output: String::new(),
101 error: Some(format!("Failed to resolve file path: {e}")),
102 });
103 }
104 };
105
106 if !self.security.is_resolved_path_allowed(&resolved_parent) {
107 return Ok(ToolResult {
108 success: false,
109 output: String::new(),
110 error: Some(
111 self.security
112 .resolved_path_violation_message(&resolved_parent),
113 ),
114 });
115 }
116
117 let Some(file_name) = full_path.file_name() else {
118 return Ok(ToolResult {
119 success: false,
120 output: String::new(),
121 error: Some("Invalid path: missing file name".into()),
122 });
123 };
124
125 let resolved_target = resolved_parent.join(file_name);
126
127 if self.security.is_runtime_config_path(&resolved_target) {
128 return Ok(ToolResult {
129 success: false,
130 output: String::new(),
131 error: Some(
132 self.security
133 .runtime_config_violation_message(&resolved_target),
134 ),
135 });
136 }
137
138 if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
140 if meta.file_type().is_symlink() {
141 return Ok(ToolResult {
142 success: false,
143 output: String::new(),
144 error: Some(format!(
145 "Refusing to write through symlink: {}",
146 resolved_target.display()
147 )),
148 });
149 }
150 }
151
152 if !self.security.record_action() {
153 return Ok(ToolResult {
154 success: false,
155 output: String::new(),
156 error: Some("Rate limit exceeded: action budget exhausted".into()),
157 });
158 }
159
160 match tokio::fs::write(&resolved_target, content).await {
161 Ok(()) => Ok(ToolResult {
162 success: true,
163 output: format!("Written {} bytes to {path}", content.len()),
164 error: None,
165 }),
166 Err(e) => Ok(ToolResult {
167 success: false,
168 output: String::new(),
169 error: Some(format!("Failed to write file: {e}")),
170 }),
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::security::{AutonomyLevel, SecurityPolicy};
179
180 fn test_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
181 Arc::new(SecurityPolicy {
182 autonomy: AutonomyLevel::Supervised,
183 workspace_dir: workspace,
184 ..SecurityPolicy::default()
185 })
186 }
187
188 fn test_security_with(
189 workspace: std::path::PathBuf,
190 autonomy: AutonomyLevel,
191 max_actions_per_hour: u32,
192 ) -> Arc<SecurityPolicy> {
193 Arc::new(SecurityPolicy {
194 autonomy,
195 workspace_dir: workspace,
196 max_actions_per_hour,
197 ..SecurityPolicy::default()
198 })
199 }
200
201 #[test]
202 fn file_write_name() {
203 let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
204 assert_eq!(tool.name(), "file_write");
205 }
206
207 #[test]
208 fn file_write_schema_has_path_and_content() {
209 let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
210 let schema = tool.parameters_schema();
211 assert!(schema["properties"]["path"].is_object());
212 assert!(schema["properties"]["content"].is_object());
213 let required = schema["required"].as_array().unwrap();
214 assert!(required.contains(&json!("path")));
215 assert!(required.contains(&json!("content")));
216 }
217
218 #[tokio::test]
219 async fn file_write_creates_file() {
220 let dir = std::env::temp_dir().join("construct_test_file_write");
221 let _ = tokio::fs::remove_dir_all(&dir).await;
222 tokio::fs::create_dir_all(&dir).await.unwrap();
223
224 let tool = FileWriteTool::new(test_security(dir.clone()));
225 let result = tool
226 .execute(json!({"path": "out.txt", "content": "written!"}))
227 .await
228 .unwrap();
229 assert!(result.success);
230 assert!(result.output.contains("8 bytes"));
231
232 let content = tokio::fs::read_to_string(dir.join("out.txt"))
233 .await
234 .unwrap();
235 assert_eq!(content, "written!");
236
237 let _ = tokio::fs::remove_dir_all(&dir).await;
238 }
239
240 #[tokio::test]
241 async fn file_write_creates_parent_dirs() {
242 let dir = std::env::temp_dir().join("construct_test_file_write_nested");
243 let _ = tokio::fs::remove_dir_all(&dir).await;
244 tokio::fs::create_dir_all(&dir).await.unwrap();
245
246 let tool = FileWriteTool::new(test_security(dir.clone()));
247 let result = tool
248 .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
249 .await
250 .unwrap();
251 assert!(result.success);
252
253 let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
254 .await
255 .unwrap();
256 assert_eq!(content, "deep");
257
258 let _ = tokio::fs::remove_dir_all(&dir).await;
259 }
260
261 #[tokio::test]
262 async fn file_write_normalizes_workspace_prefixed_relative_path() {
263 let root = std::env::temp_dir().join("construct_test_file_write_workspace_prefixed");
264 let workspace = root.join("workspace");
265 let _ = tokio::fs::remove_dir_all(&root).await;
266 tokio::fs::create_dir_all(&workspace).await.unwrap();
267
268 let tool = FileWriteTool::new(test_security(workspace.clone()));
269 let workspace_prefixed = workspace
270 .strip_prefix(std::path::Path::new("/"))
271 .unwrap()
272 .join("nested/out.txt");
273 let result = tool
274 .execute(json!({
275 "path": workspace_prefixed.to_string_lossy(),
276 "content": "written!"
277 }))
278 .await
279 .unwrap();
280 assert!(result.success);
281
282 let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
283 .await
284 .unwrap();
285 assert_eq!(content, "written!");
286 assert!(!workspace.join(workspace_prefixed).exists());
287
288 let _ = tokio::fs::remove_dir_all(&root).await;
289 }
290
291 #[tokio::test]
292 async fn file_write_overwrites_existing() {
293 let dir = std::env::temp_dir().join("construct_test_file_write_overwrite");
294 let _ = tokio::fs::remove_dir_all(&dir).await;
295 tokio::fs::create_dir_all(&dir).await.unwrap();
296 tokio::fs::write(dir.join("exist.txt"), "old")
297 .await
298 .unwrap();
299
300 let tool = FileWriteTool::new(test_security(dir.clone()));
301 let result = tool
302 .execute(json!({"path": "exist.txt", "content": "new"}))
303 .await
304 .unwrap();
305 assert!(result.success);
306
307 let content = tokio::fs::read_to_string(dir.join("exist.txt"))
308 .await
309 .unwrap();
310 assert_eq!(content, "new");
311
312 let _ = tokio::fs::remove_dir_all(&dir).await;
313 }
314
315 #[tokio::test]
316 async fn file_write_blocks_path_traversal() {
317 let dir = std::env::temp_dir().join("construct_test_file_write_traversal");
318 let _ = tokio::fs::remove_dir_all(&dir).await;
319 tokio::fs::create_dir_all(&dir).await.unwrap();
320
321 let tool = FileWriteTool::new(test_security(dir.clone()));
322 let result = tool
323 .execute(json!({"path": "../../etc/evil", "content": "bad"}))
324 .await
325 .unwrap();
326 assert!(!result.success);
327 assert!(result.error.as_ref().unwrap().contains("not allowed"));
328
329 let _ = tokio::fs::remove_dir_all(&dir).await;
330 }
331
332 #[tokio::test]
333 async fn file_write_blocks_absolute_path() {
334 let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
335 let result = tool
336 .execute(json!({"path": "/etc/evil", "content": "bad"}))
337 .await
338 .unwrap();
339 assert!(!result.success);
340 assert!(result.error.as_ref().unwrap().contains("not allowed"));
341 }
342
343 #[tokio::test]
344 async fn file_write_missing_path_param() {
345 let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
346 let result = tool.execute(json!({"content": "data"})).await;
347 assert!(result.is_err());
348 }
349
350 #[tokio::test]
351 async fn file_write_missing_content_param() {
352 let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
353 let result = tool.execute(json!({"path": "file.txt"})).await;
354 assert!(result.is_err());
355 }
356
357 #[tokio::test]
358 async fn file_write_empty_content() {
359 let dir = std::env::temp_dir().join("construct_test_file_write_empty");
360 let _ = tokio::fs::remove_dir_all(&dir).await;
361 tokio::fs::create_dir_all(&dir).await.unwrap();
362
363 let tool = FileWriteTool::new(test_security(dir.clone()));
364 let result = tool
365 .execute(json!({"path": "empty.txt", "content": ""}))
366 .await
367 .unwrap();
368 assert!(result.success);
369 assert!(result.output.contains("0 bytes"));
370
371 let _ = tokio::fs::remove_dir_all(&dir).await;
372 }
373
374 #[cfg(unix)]
375 #[tokio::test]
376 async fn file_write_blocks_symlink_escape() {
377 use std::os::unix::fs::symlink;
378
379 let root = std::env::temp_dir().join("construct_test_file_write_symlink_escape");
380 let workspace = root.join("workspace");
381 let outside = root.join("outside");
382
383 let _ = tokio::fs::remove_dir_all(&root).await;
384 tokio::fs::create_dir_all(&workspace).await.unwrap();
385 tokio::fs::create_dir_all(&outside).await.unwrap();
386
387 symlink(&outside, workspace.join("escape_dir")).unwrap();
388
389 let tool = FileWriteTool::new(test_security(workspace.clone()));
390 let result = tool
391 .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"}))
392 .await
393 .unwrap();
394
395 assert!(!result.success);
396 assert!(
397 result
398 .error
399 .as_deref()
400 .unwrap_or("")
401 .contains("escapes workspace")
402 );
403 assert!(!outside.join("hijack.txt").exists());
404
405 let _ = tokio::fs::remove_dir_all(&root).await;
406 }
407
408 #[tokio::test]
409 async fn file_write_blocks_readonly_mode() {
410 let dir = std::env::temp_dir().join("construct_test_file_write_readonly");
411 let _ = tokio::fs::remove_dir_all(&dir).await;
412 tokio::fs::create_dir_all(&dir).await.unwrap();
413
414 let tool = FileWriteTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20));
415 let result = tool
416 .execute(json!({"path": "out.txt", "content": "should-block"}))
417 .await
418 .unwrap();
419
420 assert!(!result.success);
421 assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
422 assert!(!dir.join("out.txt").exists());
423
424 let _ = tokio::fs::remove_dir_all(&dir).await;
425 }
426
427 #[tokio::test]
428 async fn file_write_blocks_when_rate_limited() {
429 let dir = std::env::temp_dir().join("construct_test_file_write_rate_limited");
430 let _ = tokio::fs::remove_dir_all(&dir).await;
431 tokio::fs::create_dir_all(&dir).await.unwrap();
432
433 let tool = FileWriteTool::new(test_security_with(
434 dir.clone(),
435 AutonomyLevel::Supervised,
436 0,
437 ));
438 let result = tool
439 .execute(json!({"path": "out.txt", "content": "should-block"}))
440 .await
441 .unwrap();
442
443 assert!(!result.success);
444 assert!(
445 result
446 .error
447 .as_deref()
448 .unwrap_or("")
449 .contains("Rate limit exceeded")
450 );
451 assert!(!dir.join("out.txt").exists());
452
453 let _ = tokio::fs::remove_dir_all(&dir).await;
454 }
455
456 #[cfg(unix)]
459 #[tokio::test]
460 async fn file_write_blocks_symlink_target_file() {
461 use std::os::unix::fs::symlink;
462
463 let root = std::env::temp_dir().join("construct_test_file_write_symlink_target");
464 let workspace = root.join("workspace");
465 let outside = root.join("outside");
466
467 let _ = tokio::fs::remove_dir_all(&root).await;
468 tokio::fs::create_dir_all(&workspace).await.unwrap();
469 tokio::fs::create_dir_all(&outside).await.unwrap();
470
471 tokio::fs::write(outside.join("target.txt"), "original")
473 .await
474 .unwrap();
475 symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
476
477 let tool = FileWriteTool::new(test_security(workspace.clone()));
478 let result = tool
479 .execute(json!({"path": "linked.txt", "content": "overwritten"}))
480 .await
481 .unwrap();
482
483 assert!(!result.success, "writing through symlink must be blocked");
484 assert!(
485 result.error.as_deref().unwrap_or("").contains("symlink"),
486 "error should mention symlink"
487 );
488
489 let content = tokio::fs::read_to_string(outside.join("target.txt"))
491 .await
492 .unwrap();
493 assert_eq!(content, "original", "original file must not be modified");
494
495 let _ = tokio::fs::remove_dir_all(&root).await;
496 }
497
498 #[tokio::test]
499 async fn file_write_absolute_path_in_workspace() {
500 let dir = std::env::temp_dir().join("construct_test_file_write_abs_path");
501 let _ = tokio::fs::remove_dir_all(&dir).await;
502 tokio::fs::create_dir_all(&dir).await.unwrap();
503
504 let dir = tokio::fs::canonicalize(&dir).await.unwrap();
506
507 let tool = FileWriteTool::new(test_security(dir.clone()));
508
509 let abs_path = dir.join("abs_test.txt");
511 let result = tool
512 .execute(
513 json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
514 )
515 .await
516 .unwrap();
517
518 assert!(
519 result.success,
520 "writing via absolute workspace path should succeed, error: {:?}",
521 result.error
522 );
523
524 let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
525 .await
526 .unwrap();
527 assert_eq!(content, "absolute!");
528
529 let _ = tokio::fs::remove_dir_all(&dir).await;
530 }
531
532 #[tokio::test]
533 async fn file_write_blocks_null_byte_in_path() {
534 let dir = std::env::temp_dir().join("construct_test_file_write_null");
535 let _ = tokio::fs::remove_dir_all(&dir).await;
536 tokio::fs::create_dir_all(&dir).await.unwrap();
537
538 let tool = FileWriteTool::new(test_security(dir.clone()));
539 let result = tool
540 .execute(json!({"path": "file\u{0000}.txt", "content": "bad"}))
541 .await
542 .unwrap();
543 assert!(!result.success, "paths with null bytes must be blocked");
544
545 let _ = tokio::fs::remove_dir_all(&dir).await;
546 }
547
548 #[tokio::test]
549 async fn file_write_blocks_runtime_config_path() {
550 let root = std::env::temp_dir().join("construct_test_file_write_runtime_config");
551 let workspace = root.join("workspace");
552 let config_path = root.join("config.toml");
553 let _ = tokio::fs::remove_dir_all(&root).await;
554 tokio::fs::create_dir_all(&workspace).await.unwrap();
555
556 let security = Arc::new(SecurityPolicy {
557 autonomy: AutonomyLevel::Supervised,
558 workspace_dir: workspace.clone(),
559 workspace_only: false,
560 allowed_roots: vec![root.clone()],
561 forbidden_paths: vec![],
562 ..SecurityPolicy::default()
563 });
564 let tool = FileWriteTool::new(security);
565 let result = tool
566 .execute(json!({
567 "path": config_path.to_string_lossy(),
568 "content": "auto_approve = [\"cron_add\"]"
569 }))
570 .await
571 .unwrap();
572
573 assert!(!result.success);
574 assert!(
575 result
576 .error
577 .unwrap_or_default()
578 .contains("runtime config/state file")
579 );
580
581 let _ = tokio::fs::remove_dir_all(&root).await;
582 }
583}