Skip to main content

construct/tools/
file_write.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6
7/// Write file contents with path sandboxing
8pub 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        // Security check: validate path is within workspace
73        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        // Ensure parent directory exists
92        tokio::fs::create_dir_all(parent).await?;
93
94        // Resolve parent AFTER creation to block symlink escapes.
95        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 the target already exists and is a symlink, refuse to follow it
139        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    // ── §5.1 TOCTOU / symlink file write protection tests ────
457
458    #[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        // Create a file outside and symlink to it inside workspace
472        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        // Verify original file was not modified
490        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        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
505        let dir = tokio::fs::canonicalize(&dir).await.unwrap();
506
507        let tool = FileWriteTool::new(test_security(dir.clone()));
508
509        // Pass an absolute path that is within the workspace
510        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}