Skip to main content

agent_air_runtime/controller/tools/
write_file.rs

1//! WriteFile tool implementation
2//!
3//! This tool allows the LLM to write files to the local filesystem.
4//! It integrates with the PermissionRegistry to require user approval
5//! before performing write operations.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tokio::fs;
14
15use super::types::{
16    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
17};
18use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
19
20/// WriteFile tool name constant.
21pub const WRITE_FILE_TOOL_NAME: &str = "write_file";
22
23/// WriteFile tool description constant.
24pub const WRITE_FILE_TOOL_DESCRIPTION: &str = r#"Writes content to a file, creating it if it doesn't exist or overwriting if it does.
25
26Usage:
27- The file_path parameter must be an absolute path, not a relative path
28- This tool will overwrite the existing file if there is one at the provided path
29- Parent directories will be created automatically if they don't exist
30- Requires user permission before writing (may be cached for session)
31
32Returns:
33- Success message with bytes written on successful write
34- Error message if permission is denied or the operation fails"#;
35
36/// WriteFile tool JSON schema constant.
37pub const WRITE_FILE_TOOL_SCHEMA: &str = r#"{
38    "type": "object",
39    "properties": {
40        "file_path": {
41            "type": "string",
42            "description": "The absolute path to the file to write"
43        },
44        "content": {
45            "type": "string",
46            "description": "The content to write to the file"
47        },
48        "create_directories": {
49            "type": "boolean",
50            "description": "Whether to create parent directories if they don't exist. Defaults to true."
51        }
52    },
53    "required": ["file_path", "content"]
54}"#;
55
56/// Tool that writes files to the filesystem with permission checks.
57pub struct WriteFileTool {
58    /// Reference to the permission registry for requesting write permissions.
59    permission_registry: Arc<PermissionRegistry>,
60}
61
62impl WriteFileTool {
63    /// Create a new WriteFileTool with the given permission registry.
64    ///
65    /// # Arguments
66    /// * `permission_registry` - The registry used to request and cache permissions.
67    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
68        Self {
69            permission_registry,
70        }
71    }
72
73    /// Builds a permission request for writing to a file.
74    ///
75    /// # Arguments
76    /// * `tool_use_id` - Unique identifier for this tool invocation
77    /// * `file_path` - Path to the file being written
78    /// * `content_len` - Number of bytes to write
79    /// * `is_overwrite` - Whether this overwrites an existing file
80    /// * `will_create_directories` - Whether parent directories will be created
81    fn build_permission_request(
82        tool_use_id: &str,
83        file_path: &str,
84        content_len: usize,
85        is_overwrite: bool,
86        will_create_directories: bool,
87    ) -> PermissionRequest {
88        let action_verb = if is_overwrite { "Overwrite" } else { "Create" };
89        let dir_note = if will_create_directories {
90            " (will create parent directories)"
91        } else {
92            ""
93        };
94        let reason = format!(
95            "{} file with {} bytes of content{}",
96            action_verb.to_lowercase(),
97            content_len,
98            dir_note
99        );
100
101        PermissionRequest::new(
102            tool_use_id,
103            GrantTarget::path(file_path, false),
104            PermissionLevel::Write,
105            format!("Write file: {}", file_path),
106        )
107        .with_reason(reason)
108        .with_tool(WRITE_FILE_TOOL_NAME)
109    }
110}
111
112impl Executable for WriteFileTool {
113    fn name(&self) -> &str {
114        WRITE_FILE_TOOL_NAME
115    }
116
117    fn description(&self) -> &str {
118        WRITE_FILE_TOOL_DESCRIPTION
119    }
120
121    fn input_schema(&self) -> &str {
122        WRITE_FILE_TOOL_SCHEMA
123    }
124
125    fn tool_type(&self) -> ToolType {
126        ToolType::TextEdit
127    }
128
129    fn execute(
130        &self,
131        context: ToolContext,
132        input: HashMap<String, serde_json::Value>,
133    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
134        let permission_registry = self.permission_registry.clone();
135
136        Box::pin(async move {
137            // ─────────────────────────────────────────────────────────────
138            // Step 1: Extract and validate parameters
139            // ─────────────────────────────────────────────────────────────
140            let file_path = input
141                .get("file_path")
142                .and_then(|v| v.as_str())
143                .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
144
145            let content = input
146                .get("content")
147                .and_then(|v| v.as_str())
148                .ok_or_else(|| "Missing required 'content' parameter".to_string())?;
149
150            let create_directories = input
151                .get("create_directories")
152                .and_then(|v| v.as_bool())
153                .unwrap_or(true);
154
155            let path = Path::new(file_path);
156
157            // Validate absolute path
158            if !path.is_absolute() {
159                return Err(format!(
160                    "file_path must be an absolute path, got: {}",
161                    file_path
162                ));
163            }
164
165            // Check if this is an overwrite (file exists) or create (new file)
166            let is_overwrite = path.exists();
167
168            // ─────────────────────────────────────────────────────────────
169            // Step 2: Determine if directories will be created
170            // ─────────────────────────────────────────────────────────────
171            let will_create_directories =
172                create_directories && path.parent().map(|p| !p.exists()).unwrap_or(false);
173
174            // ─────────────────────────────────────────────────────────────
175            // Step 3: Request permission if not pre-approved by batch executor
176            // ─────────────────────────────────────────────────────────────
177            if !context.permissions_pre_approved {
178                let permission_request = Self::build_permission_request(
179                    &context.tool_use_id,
180                    file_path,
181                    content.len(),
182                    is_overwrite,
183                    will_create_directories,
184                );
185
186                let response_rx = permission_registry
187                    .request_permission(
188                        context.session_id,
189                        permission_request,
190                        context.turn_id.clone(),
191                    )
192                    .await
193                    .map_err(|e| format!("Failed to request permission: {}", e))?;
194
195                let response = response_rx
196                    .await
197                    .map_err(|_| "Permission request was cancelled".to_string())?;
198
199                if !response.granted {
200                    let reason = response
201                        .message
202                        .unwrap_or_else(|| "Permission denied by user".to_string());
203                    return Err(format!(
204                        "Permission denied to write '{}': {}",
205                        file_path, reason
206                    ));
207                }
208            }
209
210            // ─────────────────────────────────────────────────────────────
211            // Step 7: Create parent directories if requested
212            // ─────────────────────────────────────────────────────────────
213            if create_directories
214                && let Some(parent) = path.parent()
215                && !parent.exists()
216            {
217                fs::create_dir_all(parent)
218                    .await
219                    .map_err(|e| format!("Failed to create parent directories: {}", e))?;
220            }
221
222            // ─────────────────────────────────────────────────────────────
223            // Step 8: Perform the write operation
224            // ─────────────────────────────────────────────────────────────
225            let bytes_written = content.len();
226            fs::write(path, content)
227                .await
228                .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
229
230            let action = if is_overwrite { "overwrote" } else { "created" };
231            Ok(format!(
232                "Successfully {} '{}' ({} bytes)",
233                action, file_path, bytes_written
234            ))
235        })
236    }
237
238    fn display_config(&self) -> DisplayConfig {
239        DisplayConfig {
240            display_name: "Write File".to_string(),
241            display_title: Box::new(|input| {
242                input
243                    .get("file_path")
244                    .and_then(|v| v.as_str())
245                    .map(|p| {
246                        Path::new(p)
247                            .file_name()
248                            .and_then(|n| n.to_str())
249                            .unwrap_or(p)
250                            .to_string()
251                    })
252                    .unwrap_or_default()
253            }),
254            display_content: Box::new(|input, result| {
255                let content_preview = input
256                    .get("content")
257                    .and_then(|v| v.as_str())
258                    .map(|c| {
259                        let lines: Vec<&str> = c.lines().take(10).collect();
260                        if c.lines().count() > 10 {
261                            format!("{}...\n[truncated]", lines.join("\n"))
262                        } else {
263                            lines.join("\n")
264                        }
265                    })
266                    .unwrap_or_else(|| result.to_string());
267
268                DisplayResult {
269                    content: content_preview,
270                    content_type: ResultContentType::PlainText,
271                    is_truncated: input
272                        .get("content")
273                        .and_then(|v| v.as_str())
274                        .map(|c| c.lines().count() > 10)
275                        .unwrap_or(false),
276                    full_length: input
277                        .get("content")
278                        .and_then(|v| v.as_str())
279                        .map(|c| c.lines().count())
280                        .unwrap_or(0),
281                }
282            }),
283        }
284    }
285
286    fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, _result: &str) -> String {
287        let filename = input
288            .get("file_path")
289            .and_then(|v| v.as_str())
290            .map(|p| {
291                Path::new(p)
292                    .file_name()
293                    .and_then(|n| n.to_str())
294                    .unwrap_or(p)
295            })
296            .unwrap_or("unknown");
297
298        let bytes = input
299            .get("content")
300            .and_then(|v| v.as_str())
301            .map(|c| c.len())
302            .unwrap_or(0);
303
304        format!("[WriteFile: {} ({} bytes)]", filename, bytes)
305    }
306
307    fn required_permissions(
308        &self,
309        context: &ToolContext,
310        input: &HashMap<String, serde_json::Value>,
311    ) -> Option<Vec<PermissionRequest>> {
312        // Extract file_path parameter
313        let file_path = input.get("file_path").and_then(|v| v.as_str())?;
314
315        // Extract content to determine size
316        let content = input.get("content").and_then(|v| v.as_str())?;
317
318        let path = Path::new(file_path);
319
320        // Validate absolute path - return None if invalid
321        if !path.is_absolute() {
322            return None;
323        }
324
325        // Check if this is an overwrite (file exists) or create (new file)
326        let is_overwrite = path.exists();
327
328        // Check if directories will be created (default is true)
329        let create_directories = input
330            .get("create_directories")
331            .and_then(|v| v.as_bool())
332            .unwrap_or(true);
333        let will_create_directories =
334            create_directories && path.parent().map(|p| !p.exists()).unwrap_or(false);
335
336        // Build and return permission request
337        let permission_request = Self::build_permission_request(
338            &context.tool_use_id,
339            file_path,
340            content.len(),
341            is_overwrite,
342            will_create_directories,
343        );
344
345        Some(vec![permission_request])
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::controller::PermissionPanelResponse;
353    use crate::controller::types::ControllerEvent;
354    use crate::permissions::PermissionLevel;
355    use tempfile::TempDir;
356    use tokio::sync::mpsc;
357
358    /// Helper to create a permission registry for testing.
359    fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
360        let (tx, rx) = mpsc::channel(16);
361        let registry = Arc::new(PermissionRegistry::new(tx));
362        (registry, rx)
363    }
364
365    fn grant_once() -> PermissionPanelResponse {
366        PermissionPanelResponse {
367            granted: true,
368            grant: None,
369            message: None,
370        }
371    }
372
373    fn deny(reason: &str) -> PermissionPanelResponse {
374        PermissionPanelResponse {
375            granted: false,
376            grant: None,
377            message: Some(reason.to_string()),
378        }
379    }
380
381    #[tokio::test]
382    async fn test_write_new_file_with_permission_granted() {
383        let (registry, mut event_rx) = create_test_registry();
384        let tool = WriteFileTool::new(registry.clone());
385        let temp_dir = TempDir::new().unwrap();
386        let file_path = temp_dir.path().join("test.txt");
387
388        let mut input = HashMap::new();
389        input.insert(
390            "file_path".to_string(),
391            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
392        );
393        input.insert(
394            "content".to_string(),
395            serde_json::Value::String("Hello, World!".to_string()),
396        );
397
398        let context = ToolContext {
399            session_id: 1,
400            tool_use_id: "test-123".to_string(),
401            turn_id: None,
402            permissions_pre_approved: false,
403        };
404
405        // Spawn task to handle permission request
406        let registry_clone = registry.clone();
407        tokio::spawn(async move {
408            // Wait for permission request event
409            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
410                event_rx.recv().await
411            {
412                // Grant permission
413                registry_clone
414                    .respond_to_request(&tool_use_id, grant_once())
415                    .await
416                    .unwrap();
417            }
418        });
419
420        let result = tool.execute(context, input).await;
421
422        assert!(result.is_ok());
423        assert!(file_path.exists());
424        assert_eq!(
425            tokio::fs::read_to_string(&file_path).await.unwrap(),
426            "Hello, World!"
427        );
428    }
429
430    #[tokio::test]
431    async fn test_write_file_permission_denied() {
432        let (registry, mut event_rx) = create_test_registry();
433        let tool = WriteFileTool::new(registry.clone());
434        let temp_dir = TempDir::new().unwrap();
435        let file_path = temp_dir.path().join("test.txt");
436
437        let mut input = HashMap::new();
438        input.insert(
439            "file_path".to_string(),
440            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
441        );
442        input.insert(
443            "content".to_string(),
444            serde_json::Value::String("Hello, World!".to_string()),
445        );
446
447        let context = ToolContext {
448            session_id: 1,
449            tool_use_id: "test-456".to_string(),
450            turn_id: None,
451            permissions_pre_approved: false,
452        };
453
454        // Spawn task to deny permission
455        let registry_clone = registry.clone();
456        tokio::spawn(async move {
457            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
458                event_rx.recv().await
459            {
460                // Deny permission
461                registry_clone
462                    .respond_to_request(&tool_use_id, deny("Not allowed"))
463                    .await
464                    .unwrap();
465            }
466        });
467
468        let result = tool.execute(context, input).await;
469
470        assert!(result.is_err());
471        assert!(result.unwrap_err().contains("Permission denied"));
472        assert!(!file_path.exists());
473    }
474
475    #[tokio::test]
476    async fn test_write_file_session_permission_cached() {
477        let (registry, mut event_rx) = create_test_registry();
478        let tool = WriteFileTool::new(registry.clone());
479        let temp_dir = TempDir::new().unwrap();
480
481        // First write - will request permission
482        let file_path_1 = temp_dir.path().join("test1.txt");
483        let mut input_1 = HashMap::new();
484        input_1.insert(
485            "file_path".to_string(),
486            serde_json::Value::String(file_path_1.to_str().unwrap().to_string()),
487        );
488        input_1.insert(
489            "content".to_string(),
490            serde_json::Value::String("Content 1".to_string()),
491        );
492
493        let context_1 = ToolContext {
494            session_id: 1,
495            tool_use_id: "test-1".to_string(),
496            turn_id: None,
497            permissions_pre_approved: false,
498        };
499
500        // Grant with Session scope
501        let registry_clone = registry.clone();
502        tokio::spawn(async move {
503            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
504                event_rx.recv().await
505            {
506                registry_clone
507                    .respond_to_request(&tool_use_id, grant_once())
508                    .await
509                    .unwrap();
510            }
511        });
512
513        let result_1 = tool.execute(context_1, input_1).await;
514        assert!(result_1.is_ok());
515        assert!(file_path_1.exists());
516
517        // Second write - should use cached permission (no event emitted)
518        // Note: Cache matching uses action pattern, so same action "Create file: test2.txt"
519        // will NOT match "Create file: test1.txt". This is current behavior.
520        // For this test, we verify the first write worked.
521    }
522
523    #[tokio::test]
524    async fn test_overwrite_existing_file() {
525        let (registry, mut event_rx) = create_test_registry();
526        let tool = WriteFileTool::new(registry.clone());
527        let temp_dir = TempDir::new().unwrap();
528        let file_path = temp_dir.path().join("existing.txt");
529
530        // Create existing file
531        tokio::fs::write(&file_path, "old content").await.unwrap();
532
533        let mut input = HashMap::new();
534        input.insert(
535            "file_path".to_string(),
536            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
537        );
538        input.insert(
539            "content".to_string(),
540            serde_json::Value::String("new content".to_string()),
541        );
542
543        let context = ToolContext {
544            session_id: 1,
545            tool_use_id: "test-overwrite".to_string(),
546            turn_id: None,
547            permissions_pre_approved: false,
548        };
549
550        // Grant permission
551        let registry_clone = registry.clone();
552        tokio::spawn(async move {
553            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
554                event_rx.recv().await
555            {
556                registry_clone
557                    .respond_to_request(&tool_use_id, grant_once())
558                    .await
559                    .unwrap();
560            }
561        });
562
563        let result = tool.execute(context, input).await;
564
565        assert!(result.is_ok());
566        assert!(result.unwrap().contains("overwrote"));
567        assert_eq!(
568            tokio::fs::read_to_string(&file_path).await.unwrap(),
569            "new content"
570        );
571    }
572
573    #[tokio::test]
574    async fn test_create_parent_directories() {
575        let (registry, mut event_rx) = create_test_registry();
576        let tool = WriteFileTool::new(registry.clone());
577        let temp_dir = TempDir::new().unwrap();
578        let file_path = temp_dir.path().join("nested/dir/test.txt");
579
580        let mut input = HashMap::new();
581        input.insert(
582            "file_path".to_string(),
583            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
584        );
585        input.insert(
586            "content".to_string(),
587            serde_json::Value::String("nested content".to_string()),
588        );
589
590        let context = ToolContext {
591            session_id: 1,
592            tool_use_id: "test-nested".to_string(),
593            turn_id: None,
594            permissions_pre_approved: false,
595        };
596
597        // Grant permission
598        let registry_clone = registry.clone();
599        tokio::spawn(async move {
600            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
601                event_rx.recv().await
602            {
603                registry_clone
604                    .respond_to_request(&tool_use_id, grant_once())
605                    .await
606                    .unwrap();
607            }
608        });
609
610        let result = tool.execute(context, input).await;
611
612        assert!(result.is_ok());
613        assert!(file_path.exists());
614        assert!(file_path.parent().unwrap().exists());
615    }
616
617    #[tokio::test]
618    async fn test_relative_path_rejected() {
619        let (registry, _event_rx) = create_test_registry();
620        let tool = WriteFileTool::new(registry);
621
622        let mut input = HashMap::new();
623        input.insert(
624            "file_path".to_string(),
625            serde_json::Value::String("relative/path.txt".to_string()),
626        );
627        input.insert(
628            "content".to_string(),
629            serde_json::Value::String("content".to_string()),
630        );
631
632        let context = ToolContext {
633            session_id: 1,
634            tool_use_id: "test".to_string(),
635            turn_id: None,
636            permissions_pre_approved: false,
637        };
638
639        let result = tool.execute(context, input).await;
640        assert!(result.is_err());
641        assert!(result.unwrap_err().contains("absolute path"));
642    }
643
644    #[tokio::test]
645    async fn test_missing_file_path() {
646        let (registry, _event_rx) = create_test_registry();
647        let tool = WriteFileTool::new(registry);
648
649        let mut input = HashMap::new();
650        input.insert(
651            "content".to_string(),
652            serde_json::Value::String("content".to_string()),
653        );
654
655        let context = ToolContext {
656            session_id: 1,
657            tool_use_id: "test".to_string(),
658            turn_id: None,
659            permissions_pre_approved: false,
660        };
661
662        let result = tool.execute(context, input).await;
663        assert!(result.is_err());
664        assert!(result.unwrap_err().contains("Missing required 'file_path'"));
665    }
666
667    #[tokio::test]
668    async fn test_missing_content() {
669        let (registry, _event_rx) = create_test_registry();
670        let tool = WriteFileTool::new(registry);
671
672        let mut input = HashMap::new();
673        input.insert(
674            "file_path".to_string(),
675            serde_json::Value::String("/tmp/test.txt".to_string()),
676        );
677
678        let context = ToolContext {
679            session_id: 1,
680            tool_use_id: "test".to_string(),
681            turn_id: None,
682            permissions_pre_approved: false,
683        };
684
685        let result = tool.execute(context, input).await;
686        assert!(result.is_err());
687        assert!(result.unwrap_err().contains("Missing required 'content'"));
688    }
689
690    #[test]
691    fn test_compact_summary() {
692        let (registry, _event_rx) = create_test_registry();
693        let tool = WriteFileTool::new(registry);
694
695        let mut input = HashMap::new();
696        input.insert(
697            "file_path".to_string(),
698            serde_json::Value::String("/path/to/file.rs".to_string()),
699        );
700        input.insert(
701            "content".to_string(),
702            serde_json::Value::String("some content here".to_string()),
703        );
704
705        let summary = tool.compact_summary(&input, "Successfully created...");
706        assert_eq!(summary, "[WriteFile: file.rs (17 bytes)]");
707    }
708
709    #[test]
710    fn test_build_permission_request_create() {
711        let request = WriteFileTool::build_permission_request(
712            "test-id",
713            "/path/to/new.txt",
714            100,
715            false,
716            false,
717        );
718
719        assert_eq!(request.description, "Write file: /path/to/new.txt");
720        assert_eq!(
721            request.reason,
722            Some("create file with 100 bytes of content".to_string())
723        );
724        assert_eq!(request.target, GrantTarget::path("/path/to/new.txt", false));
725        assert_eq!(request.required_level, PermissionLevel::Write);
726    }
727
728    #[test]
729    fn test_build_permission_request_overwrite() {
730        let request = WriteFileTool::build_permission_request(
731            "test-id",
732            "/path/to/existing.txt",
733            500,
734            true,
735            false,
736        );
737
738        assert_eq!(request.description, "Write file: /path/to/existing.txt");
739        assert_eq!(
740            request.reason,
741            Some("overwrite file with 500 bytes of content".to_string())
742        );
743        assert_eq!(
744            request.target,
745            GrantTarget::path("/path/to/existing.txt", false)
746        );
747        assert_eq!(request.required_level, PermissionLevel::Write);
748    }
749
750    #[test]
751    fn test_build_permission_request_with_directory_creation() {
752        let request = WriteFileTool::build_permission_request(
753            "test-id",
754            "/new/path/file.txt",
755            200,
756            false,
757            true,
758        );
759
760        assert_eq!(request.description, "Write file: /new/path/file.txt");
761        assert_eq!(
762            request.reason,
763            Some(
764                "create file with 200 bytes of content (will create parent directories)"
765                    .to_string()
766            )
767        );
768        assert_eq!(
769            request.target,
770            GrantTarget::path("/new/path/file.txt", false)
771        );
772        assert_eq!(request.required_level, PermissionLevel::Write);
773    }
774}