1use super::error::{ErrorCategory, format_error_for_llm};
19use super::response::{
20 format_cancelled, format_file_content, format_file_content_range, format_list,
21};
22use super::truncation::{TruncationLimits, truncate_dir_listing, truncate_file_content};
23use crate::agent::ide::IdeClient;
24use crate::agent::ui::confirmation::ConfirmationResult;
25use crate::agent::ui::diff::{confirm_file_write, confirm_file_write_with_ide};
26use rig::completion::ToolDefinition;
27use rig::tool::Tool;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use std::collections::HashSet;
31use std::fs;
32use std::path::PathBuf;
33use std::sync::Mutex;
34
35#[derive(Debug, Deserialize)]
40pub struct ReadFileArgs {
41 pub path: String,
42 pub start_line: Option<u64>,
43 pub end_line: Option<u64>,
44}
45
46#[derive(Debug, thiserror::Error)]
47#[error("Read file error: {0}")]
48pub struct ReadFileError(String);
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ReadFileTool {
52 project_path: PathBuf,
53}
54
55impl ReadFileTool {
56 pub fn new(project_path: PathBuf) -> Self {
57 Self { project_path }
58 }
59
60 fn is_likely_binary(content: &[u8]) -> bool {
62 let check_len = content.len().min(1024);
63 content[..check_len].contains(&0)
64 }
65
66 fn validate_symlink_target(&self, path: &PathBuf) -> Result<PathBuf, String> {
68 let canonical_project = self.project_path.canonicalize().map_err(|e| {
69 format_error_for_llm(
70 "read_file",
71 ErrorCategory::InternalError,
72 &format!("Invalid project path: {}", e),
73 Some(vec!["This is an internal configuration error"]),
74 )
75 })?;
76
77 let target = fs::read_link(path).map_err(|e| {
79 format_error_for_llm(
80 "read_file",
81 ErrorCategory::FileNotFound,
82 &format!("Cannot read symlink '{}': {}", path.display(), e),
83 Some(vec!["The symlink may be broken or inaccessible"]),
84 )
85 })?;
86
87 let resolved = if target.is_absolute() {
89 target.clone()
90 } else {
91 path.parent().unwrap_or(path).join(&target)
92 };
93
94 let canonical_target = match resolved.canonicalize() {
96 Ok(p) => p,
97 Err(e) => {
98 let hint1 = format!(
99 "Symlink '{}' points to '{}'",
100 path.display(),
101 target.display()
102 );
103 let hint2 = format!("Error: {}", e);
104 return Err(format_error_for_llm(
105 "read_file",
106 ErrorCategory::FileNotFound,
107 &format!("Symlink target does not exist: {}", resolved.display()),
108 Some(vec![&hint1, &hint2]),
109 ));
110 }
111 };
112
113 if !canonical_target.starts_with(&canonical_project) {
115 let hint_symlink = format!("Symlink: {}", path.display());
116 let hint_target = format!("Target: {}", target.display());
117 let hint_project = format!("Project root: {}", self.project_path.display());
118 return Err(format_error_for_llm(
119 "read_file",
120 ErrorCategory::PathOutsideBoundary,
121 &format!(
122 "Symlink target '{}' is outside project boundary",
123 target.display()
124 ),
125 Some(vec![
126 "The symlink points to a location outside the project directory",
127 &hint_symlink,
128 &hint_target,
129 &hint_project,
130 ]),
131 ));
132 }
133
134 Ok(canonical_target)
135 }
136
137 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
140 let canonical_project = self.project_path.canonicalize().map_err(|e| {
141 format_error_for_llm(
142 "read_file",
143 ErrorCategory::InternalError,
144 &format!("Invalid project path: {}", e),
145 Some(vec!["This is an internal configuration error"]),
146 )
147 })?;
148
149 let target = if requested.is_absolute() {
150 requested.clone()
151 } else {
152 self.project_path.join(requested)
153 };
154
155 let canonical_target = target.canonicalize().map_err(|e| {
156 let kind = e.kind();
157 match kind {
158 std::io::ErrorKind::NotFound => format_error_for_llm(
159 "read_file",
160 ErrorCategory::FileNotFound,
161 &format!("File not found: {}", requested.display()),
162 Some(vec![
163 "Check if the file path is spelled correctly",
164 "Use list_directory to explore available files",
165 &format!("Project root: {}", self.project_path.display()),
166 ]),
167 ),
168 std::io::ErrorKind::PermissionDenied => format_error_for_llm(
169 "read_file",
170 ErrorCategory::PermissionDenied,
171 &format!("Permission denied: {}", requested.display()),
172 Some(vec![
173 "The file exists but cannot be read due to permissions",
174 ]),
175 ),
176 _ => format_error_for_llm(
177 "read_file",
178 ErrorCategory::FileNotFound,
179 &format!("Cannot access file '{}': {}", requested.display(), e),
180 Some(vec!["Verify the path exists and is accessible"]),
181 ),
182 }
183 })?;
184
185 if !canonical_target.starts_with(&canonical_project) {
186 return Err(format_error_for_llm(
187 "read_file",
188 ErrorCategory::PathOutsideBoundary,
189 &format!("Path '{}' is outside project boundary", requested.display()),
190 Some(vec![
191 "Paths must be within the project directory",
192 "Use relative paths from project root",
193 &format!("Project root: {}", self.project_path.display()),
194 ]),
195 ));
196 }
197
198 Ok(canonical_target)
199 }
200}
201
202impl Tool for ReadFileTool {
203 const NAME: &'static str = "read_file";
204
205 type Error = ReadFileError;
206 type Args = ReadFileArgs;
207 type Output = String;
208
209 async fn definition(&self, _prompt: String) -> ToolDefinition {
210 ToolDefinition {
211 name: Self::NAME.to_string(),
212 description: r#"Read the contents of a file in the project.
213
214**Truncation Limits:**
215- Maximum 2000 lines returned by default
216- Lines longer than 2000 characters are truncated
217- Use start_line/end_line to read specific sections of large files
218
219**Path Restrictions:**
220- Paths must be within the project directory (security boundary)
221- Both relative and absolute paths are supported
222- Relative paths are resolved from project root
223
224**Line Range Usage:**
225- start_line: 1-based line number to start reading from
226- end_line: 1-based line number to stop at (inclusive)
227- If only start_line is provided, reads from that line to end of file
228- If start_line exceeds file length, returns an error with file size info"#
229 .to_string(),
230 parameters: json!({
231 "type": "object",
232 "properties": {
233 "path": {
234 "type": "string",
235 "description": "Path to the file to read (relative to project root or absolute within project)"
236 },
237 "start_line": {
238 "type": "integer",
239 "description": "Starting line number (1-based). Use with end_line to read specific sections of large files."
240 },
241 "end_line": {
242 "type": "integer",
243 "description": "Ending line number (1-based, inclusive). If omitted with start_line, reads to end of file."
244 }
245 },
246 "required": ["path"]
247 }),
248 }
249 }
250
251 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
252 let requested_path = PathBuf::from(&args.path);
253 let file_path = match self.validate_path(&requested_path) {
254 Ok(path) => path,
255 Err(error_msg) => return Ok(error_msg), };
257
258 let symlink_metadata = fs::symlink_metadata(&file_path)
260 .map_err(|e| ReadFileError(format!("Cannot access file: {}", e)))?;
261
262 if symlink_metadata.file_type().is_symlink() {
263 if let Err(error_msg) = self.validate_symlink_target(&file_path) {
265 return Ok(error_msg);
266 }
267 }
268
269 let metadata = fs::metadata(&file_path)
270 .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
271
272 if metadata.len() == 0 {
274 return Ok(format_file_content(&args.path, "(empty file)", 0, 0, false));
275 }
276
277 const MAX_SIZE: u64 = 1024 * 1024;
278 if metadata.len() > MAX_SIZE {
279 return Ok(format_error_for_llm(
280 "read_file",
281 ErrorCategory::ValidationFailed,
282 &format!(
283 "File too large ({} bytes). Maximum size is {} bytes.",
284 metadata.len(),
285 MAX_SIZE
286 ),
287 Some(vec![
288 "Use start_line/end_line to read specific sections",
289 "Consider if you need the entire file",
290 ]),
291 ));
292 }
293
294 let raw_content = fs::read(&file_path)
296 .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
297
298 if Self::is_likely_binary(&raw_content) {
300 return Ok(format_error_for_llm(
301 "read_file",
302 ErrorCategory::ValidationFailed,
303 &format!(
304 "File '{}' appears to be binary (contains null bytes)",
305 args.path
306 ),
307 Some(vec![
308 "This tool is designed for text files only",
309 "Binary files cannot be displayed as text",
310 "Consider using a hex viewer or specialized tool for binary files",
311 ]),
312 ));
313 }
314
315 let content = String::from_utf8_lossy(&raw_content).into_owned();
317
318 if let Some(start) = args.start_line {
320 let lines: Vec<&str> = content.lines().collect();
322 let start_idx = (start as usize).saturating_sub(1);
323 let end_idx = args
324 .end_line
325 .map(|e| (e as usize).min(lines.len()))
326 .unwrap_or(lines.len());
327
328 if start_idx >= lines.len() {
329 return Ok(format_error_for_llm(
330 "read_file",
331 ErrorCategory::ValidationFailed,
332 &format!(
333 "Start line {} exceeds file length ({} lines)",
334 start,
335 lines.len()
336 ),
337 Some(vec![
338 &format!("File has {} lines total", lines.len()),
339 "Use start_line within valid range",
340 ]),
341 ));
342 }
343
344 let end_idx = end_idx.max(start_idx);
346
347 let selected: Vec<String> = lines[start_idx..end_idx]
348 .iter()
349 .enumerate()
350 .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
351 .collect();
352
353 Ok(format_file_content_range(
354 &args.path,
355 &selected.join("\n"),
356 start as usize,
357 end_idx,
358 lines.len(),
359 ))
360 } else {
361 let limits = TruncationLimits::default();
363 let truncated = truncate_file_content(&content, &limits);
364
365 Ok(format_file_content(
366 &args.path,
367 &truncated.content,
368 truncated.total_lines,
369 truncated.returned_lines,
370 truncated.was_truncated,
371 ))
372 }
373 }
374}
375
376#[derive(Debug, Deserialize)]
381pub struct ListDirectoryArgs {
382 pub path: Option<String>,
383 pub recursive: Option<bool>,
384}
385
386#[derive(Debug, thiserror::Error)]
387#[error("List directory error: {0}")]
388pub struct ListDirectoryError(String);
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ListDirectoryTool {
392 project_path: PathBuf,
393}
394
395impl ListDirectoryTool {
396 pub fn new(project_path: PathBuf) -> Self {
397 Self { project_path }
398 }
399
400 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
403 let canonical_project = self.project_path.canonicalize().map_err(|e| {
404 format_error_for_llm(
405 "list_directory",
406 ErrorCategory::InternalError,
407 &format!("Invalid project path: {}", e),
408 Some(vec!["This is an internal configuration error"]),
409 )
410 })?;
411
412 let target = if requested.is_absolute() {
413 requested.clone()
414 } else {
415 self.project_path.join(requested)
416 };
417
418 let canonical_target = target.canonicalize().map_err(|e| {
419 let kind = e.kind();
420 match kind {
421 std::io::ErrorKind::NotFound => format_error_for_llm(
422 "list_directory",
423 ErrorCategory::FileNotFound,
424 &format!("Directory not found: {}", requested.display()),
425 Some(vec![
426 "Check if the directory path is spelled correctly",
427 "Use '.' to list the project root",
428 &format!("Project root: {}", self.project_path.display()),
429 ]),
430 ),
431 std::io::ErrorKind::PermissionDenied => format_error_for_llm(
432 "list_directory",
433 ErrorCategory::PermissionDenied,
434 &format!("Permission denied: {}", requested.display()),
435 Some(vec![
436 "The directory exists but cannot be read due to permissions",
437 ]),
438 ),
439 _ => format_error_for_llm(
440 "list_directory",
441 ErrorCategory::FileNotFound,
442 &format!("Cannot access directory '{}': {}", requested.display(), e),
443 Some(vec!["Verify the path exists and is accessible"]),
444 ),
445 }
446 })?;
447
448 if !canonical_target.starts_with(&canonical_project) {
449 return Err(format_error_for_llm(
450 "list_directory",
451 ErrorCategory::PathOutsideBoundary,
452 &format!("Path '{}' is outside project boundary", requested.display()),
453 Some(vec![
454 "Paths must be within the project directory",
455 "Use '.' for project root",
456 &format!("Project root: {}", self.project_path.display()),
457 ]),
458 ));
459 }
460
461 Ok(canonical_target)
462 }
463
464 fn list_entries(
465 &self,
466 base_path: &PathBuf,
467 current_path: &PathBuf,
468 recursive: bool,
469 depth: usize,
470 max_depth: usize,
471 entries: &mut Vec<serde_json::Value>,
472 ) -> Result<(), ListDirectoryError> {
473 let skip_dirs = [
474 "node_modules",
475 ".git",
476 "target",
477 "__pycache__",
478 ".venv",
479 "venv",
480 "dist",
481 "build",
482 ];
483
484 let dir_name = current_path
485 .file_name()
486 .and_then(|n| n.to_str())
487 .unwrap_or("");
488
489 if depth > 0 && skip_dirs.contains(&dir_name) {
490 return Ok(());
491 }
492
493 let read_dir = fs::read_dir(current_path)
494 .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
495
496 for entry in read_dir {
497 let entry =
498 entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
499 let path = entry.path();
500 let metadata = entry.metadata().ok();
501
502 let relative_path = path
503 .strip_prefix(base_path)
504 .unwrap_or(&path)
505 .to_string_lossy()
506 .to_string();
507 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
508 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
509
510 entries.push(json!({
511 "name": entry.file_name().to_string_lossy(),
512 "path": relative_path,
513 "type": if is_dir { "directory" } else { "file" },
514 "size": if is_dir { None::<u64> } else { Some(size) }
515 }));
516
517 if recursive && is_dir && depth < max_depth {
518 self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
519 }
520 }
521
522 Ok(())
523 }
524}
525
526impl Tool for ListDirectoryTool {
527 const NAME: &'static str = "list_directory";
528
529 type Error = ListDirectoryError;
530 type Args = ListDirectoryArgs;
531 type Output = String;
532
533 async fn definition(&self, _prompt: String) -> ToolDefinition {
534 ToolDefinition {
535 name: Self::NAME.to_string(),
536 description: r#"List the contents of a directory in the project.
537
538**Truncation Limits:**
539- Maximum 500 entries returned
540- Use more specific paths to explore large directories
541
542**Output Format:**
543- Returns entries sorted alphabetically by name
544- Each entry includes: name, path, type (file/directory), size (for files)
545
546**Filtering:**
547- Automatically skips common non-essential directories: node_modules, .git, target, __pycache__, .venv, venv, dist, build
548- Respects .gitignore patterns in recursive mode
549
550**Path Restrictions:**
551- Paths must be within the project directory (security boundary)
552- Use '.' or empty path for project root"#.to_string(),
553 parameters: json!({
554 "type": "object",
555 "properties": {
556 "path": {
557 "type": "string",
558 "description": "Path to the directory (relative to project root). Use '.' or omit for project root."
559 },
560 "recursive": {
561 "type": "boolean",
562 "description": "If true, list contents recursively (max depth 3, skips node_modules/.git/etc). Default: false."
563 }
564 }
565 }),
566 }
567 }
568
569 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
570 let path_str = args.path.as_deref().unwrap_or(".");
571
572 let requested_path = if path_str.is_empty() || path_str == "." {
573 self.project_path.clone()
574 } else {
575 PathBuf::from(path_str)
576 };
577
578 let dir_path = match self.validate_path(&requested_path) {
579 Ok(path) => path,
580 Err(error_msg) => return Ok(error_msg), };
582 let recursive = args.recursive.unwrap_or(false);
583
584 let mut entries = Vec::new();
585 self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
586
587 let limits = TruncationLimits::default();
589 let truncated = truncate_dir_listing(entries, limits.max_dir_entries);
590
591 Ok(format_list(
593 path_str,
594 &truncated.entries,
595 truncated.total_entries,
596 truncated.was_truncated,
597 ))
598 }
599}
600
601#[derive(Debug, Deserialize)]
606pub struct WriteFileArgs {
607 pub path: String,
609 pub content: String,
611 pub create_dirs: Option<bool>,
613}
614
615#[derive(Debug, thiserror::Error)]
616#[error("Write file error: {0}")]
617pub struct WriteFileError(String);
618
619#[derive(Debug)]
621pub struct AllowedFilePatterns {
622 patterns: Mutex<HashSet<String>>,
623}
624
625impl AllowedFilePatterns {
626 pub fn new() -> Self {
627 Self {
628 patterns: Mutex::new(HashSet::new()),
629 }
630 }
631
632 pub fn is_allowed(&self, filename: &str) -> bool {
634 let patterns = self.patterns.lock().unwrap();
635 patterns.contains(filename)
636 }
637
638 pub fn allow(&self, pattern: String) {
640 let mut patterns = self.patterns.lock().unwrap();
641 patterns.insert(pattern);
642 }
643}
644
645impl Default for AllowedFilePatterns {
646 fn default() -> Self {
647 Self::new()
648 }
649}
650
651#[derive(Debug, Clone)]
652pub struct WriteFileTool {
653 project_path: PathBuf,
654 require_confirmation: bool,
656 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
658 ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
660}
661
662impl WriteFileTool {
663 pub fn new(project_path: PathBuf) -> Self {
664 Self {
665 project_path,
666 require_confirmation: true,
667 allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
668 ide_client: None,
669 }
670 }
671
672 pub fn with_allowed_patterns(
674 project_path: PathBuf,
675 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
676 ) -> Self {
677 Self {
678 project_path,
679 require_confirmation: true,
680 allowed_patterns,
681 ide_client: None,
682 }
683 }
684
685 pub fn with_ide_client(
687 mut self,
688 ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
689 ) -> Self {
690 self.ide_client = Some(ide_client);
691 self
692 }
693
694 pub fn without_confirmation(mut self) -> Self {
696 self.require_confirmation = false;
697 self
698 }
699
700 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
703 let canonical_project = self.project_path.canonicalize().map_err(|e| {
704 format_error_for_llm(
705 "write_file",
706 ErrorCategory::InternalError,
707 &format!("Invalid project path: {}", e),
708 Some(vec!["This is an internal configuration error"]),
709 )
710 })?;
711
712 let target = if requested.is_absolute() {
713 requested.clone()
714 } else {
715 self.project_path.join(requested)
716 };
717
718 let parent = target.parent().ok_or_else(|| {
720 format_error_for_llm(
721 "write_file",
722 ErrorCategory::ValidationFailed,
723 &format!(
724 "Invalid path '{}': no parent directory",
725 requested.display()
726 ),
727 Some(vec![
728 "Provide a valid file path with at least a filename",
729 "Example: 'tmp/output.txt' or 'results/analysis.md'",
730 ]),
731 )
732 })?;
733
734 let is_within_project = if parent.exists() {
736 let canonical_parent = parent.canonicalize().map_err(|e| {
737 let kind = e.kind();
738 match kind {
739 std::io::ErrorKind::PermissionDenied => format_error_for_llm(
740 "write_file",
741 ErrorCategory::PermissionDenied,
742 &format!(
743 "Permission denied accessing parent directory: {}",
744 parent.display()
745 ),
746 Some(vec!["The parent directory exists but cannot be accessed"]),
747 ),
748 _ => format_error_for_llm(
749 "write_file",
750 ErrorCategory::ValidationFailed,
751 &format!("Invalid parent path '{}': {}", parent.display(), e),
752 Some(vec!["Verify the parent directory path is valid"]),
753 ),
754 }
755 })?;
756 canonical_parent.starts_with(&canonical_project)
757 } else {
758 let normalized = self.project_path.join(requested);
760 !normalized
761 .components()
762 .any(|c| c == std::path::Component::ParentDir)
763 };
764
765 if !is_within_project {
766 return Err(format_error_for_llm(
767 "write_file",
768 ErrorCategory::PathOutsideBoundary,
769 &format!("Path '{}' is outside project boundary", requested.display()),
770 Some(vec![
771 "SECURITY: Writes are restricted to the project directory",
772 "For temporary files, create a 'tmp/' directory in project root",
773 "Use a project-relative path like 'tmp/output.txt'",
774 &format!("Project root: {}", self.project_path.display()),
775 ]),
776 ));
777 }
778
779 Ok(target)
780 }
781}
782
783impl Tool for WriteFileTool {
784 const NAME: &'static str = "write_file";
785
786 type Error = WriteFileError;
787 type Args = WriteFileArgs;
788 type Output = String;
789
790 async fn definition(&self, _prompt: String) -> ToolDefinition {
791 ToolDefinition {
792 name: Self::NAME.to_string(),
793 description: r#"Write content to a file in the project. Creates the file if it doesn't exist, or overwrites if it does.
794
795**SECURITY: Path Restriction (Intentional)**
796- Writes are ONLY allowed within the project directory
797- Writing to /tmp, /etc, or any path outside the project is blocked
798- This is a security feature to prevent unintended system modifications
799- For temporary files, create a 'tmp/' directory within your project root
800
801**Confirmation Workflow:**
802- All writes show a diff preview before applying
803- User can approve, reject, or request modifications
804- Use 'Always' option to skip confirmation for repeated file types
805
806**IMPORTANT**: Use this tool IMMEDIATELY when the user asks you to:
807- Create ANY file (Dockerfile, .tf, .yaml, .md, .json, etc.)
808- Generate configuration files
809- Write documentation to a specific location
810- Save analysis results or findings
811
812**DO NOT** just describe what you would write - actually call this tool with the content.
813
814Use cases:
815- Generate Dockerfiles for applications
816- Create Terraform configuration files (.tf)
817- Write Helm chart templates and values
818- Create docker-compose.yml files
819- Generate CI/CD configuration files
820- Write Kubernetes manifests
821- Save analysis findings to markdown files
822
823The tool will create parent directories automatically if they don't exist."#.to_string(),
824 parameters: json!({
825 "type": "object",
826 "properties": {
827 "path": {
828 "type": "string",
829 "description": "Path to the file (relative to project root). Must be within project. Examples: 'Dockerfile', 'terraform/main.tf', 'tmp/scratch.txt'"
830 },
831 "content": {
832 "type": "string",
833 "description": "The complete content to write to the file"
834 },
835 "create_dirs": {
836 "type": "boolean",
837 "description": "If true (default), create parent directories if they don't exist"
838 }
839 },
840 "required": ["path", "content"]
841 }),
842 }
843 }
844
845 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
846 let requested_path = PathBuf::from(&args.path);
847 let file_path = match self.validate_path(&requested_path) {
848 Ok(path) => path,
849 Err(error_msg) => return Ok(error_msg), };
851
852 let old_content = if file_path.exists() {
854 fs::read_to_string(&file_path).ok()
855 } else {
856 None
857 };
858
859 let filename = std::path::Path::new(&args.path)
861 .file_name()
862 .map(|n| n.to_string_lossy().to_string())
863 .unwrap_or_else(|| args.path.clone());
864
865 let needs_confirmation =
867 self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
868
869 if needs_confirmation {
870 let ide_client_guard = if let Some(ref client) = self.ide_client {
872 Some(client.lock().await)
873 } else {
874 None
875 };
876 let ide_client_ref = ide_client_guard.as_deref();
877
878 let confirmation = confirm_file_write_with_ide(
880 &args.path,
881 old_content.as_deref(),
882 &args.content,
883 ide_client_ref,
884 )
885 .await;
886
887 match confirmation {
888 ConfirmationResult::Proceed => {
889 }
891 ConfirmationResult::ProceedAlways(pattern) => {
892 self.allowed_patterns.allow(pattern);
894 }
895 ConfirmationResult::Modify(feedback) => {
896 return Ok(format_cancelled(
898 &args.path,
899 "User requested changes",
900 Some(&feedback),
901 ));
902 }
903 ConfirmationResult::Cancel => {
904 return Ok(format_cancelled(
906 &args.path,
907 "User cancelled the operation",
908 None,
909 ));
910 }
911 }
912 } else {
913 use crate::agent::ui::diff::{render_diff, render_new_file};
915 use colored::Colorize;
916
917 if let Some(old) = &old_content {
918 render_diff(old, &args.content, &args.path);
919 } else {
920 render_new_file(&args.content, &args.path);
921 }
922 println!(" {} Auto-accepted", "✓".green());
923 }
924
925 let create_dirs = args.create_dirs.unwrap_or(true);
927 if create_dirs
928 && let Some(parent) = file_path.parent()
929 && !parent.exists()
930 {
931 fs::create_dir_all(parent)
932 .map_err(|e| WriteFileError(format!("Failed to create directories: {}", e)))?;
933 }
934
935 let file_existed = file_path.exists();
937
938 fs::write(&file_path, &args.content)
940 .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
941
942 let action = if file_existed { "Updated" } else { "Created" };
943 let lines = args.content.lines().count();
944
945 let result = json!({
946 "success": true,
947 "action": action,
948 "path": args.path,
949 "lines_written": lines,
950 "bytes_written": args.content.len()
951 });
952
953 serde_json::to_string_pretty(&result)
954 .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
955 }
956}
957
958#[derive(Debug, Deserialize)]
963pub struct FileToWrite {
964 pub path: String,
966 pub content: String,
968}
969
970#[derive(Debug, Deserialize)]
971pub struct WriteFilesArgs {
972 pub files: Vec<FileToWrite>,
974 pub create_dirs: Option<bool>,
976}
977
978#[derive(Debug, thiserror::Error)]
979#[error("Write files error: {0}")]
980pub struct WriteFilesError(String);
981
982#[derive(Debug, Clone)]
983pub struct WriteFilesTool {
984 project_path: PathBuf,
985 require_confirmation: bool,
987 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
989 ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
991}
992
993impl WriteFilesTool {
994 pub fn new(project_path: PathBuf) -> Self {
995 Self {
996 project_path,
997 require_confirmation: true,
998 allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
999 ide_client: None,
1000 }
1001 }
1002
1003 pub fn with_allowed_patterns(
1005 project_path: PathBuf,
1006 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
1007 ) -> Self {
1008 Self {
1009 project_path,
1010 require_confirmation: true,
1011 allowed_patterns,
1012 ide_client: None,
1013 }
1014 }
1015
1016 pub fn without_confirmation(mut self) -> Self {
1018 self.require_confirmation = false;
1019 self
1020 }
1021
1022 pub fn with_ide_client(
1024 mut self,
1025 ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
1026 ) -> Self {
1027 self.ide_client = Some(ide_client);
1028 self
1029 }
1030
1031 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, String> {
1034 let canonical_project = self.project_path.canonicalize().map_err(|e| {
1035 format_error_for_llm(
1036 "write_files",
1037 ErrorCategory::InternalError,
1038 &format!("Invalid project path: {}", e),
1039 Some(vec!["This is an internal configuration error"]),
1040 )
1041 })?;
1042
1043 let target = if requested.is_absolute() {
1044 requested.clone()
1045 } else {
1046 self.project_path.join(requested)
1047 };
1048
1049 let parent = target.parent().ok_or_else(|| {
1050 format_error_for_llm(
1051 "write_files",
1052 ErrorCategory::ValidationFailed,
1053 &format!(
1054 "Invalid path '{}': no parent directory",
1055 requested.display()
1056 ),
1057 Some(vec![
1058 "Provide a valid file path with at least a filename",
1059 "Example: 'tmp/output.txt' or 'results/analysis.md'",
1060 ]),
1061 )
1062 })?;
1063
1064 let is_within_project = if parent.exists() {
1065 let canonical_parent = parent.canonicalize().map_err(|e| {
1066 let kind = e.kind();
1067 match kind {
1068 std::io::ErrorKind::PermissionDenied => format_error_for_llm(
1069 "write_files",
1070 ErrorCategory::PermissionDenied,
1071 &format!(
1072 "Permission denied accessing parent directory: {}",
1073 parent.display()
1074 ),
1075 Some(vec!["The parent directory exists but cannot be accessed"]),
1076 ),
1077 _ => format_error_for_llm(
1078 "write_files",
1079 ErrorCategory::ValidationFailed,
1080 &format!("Invalid parent path '{}': {}", parent.display(), e),
1081 Some(vec!["Verify the parent directory path is valid"]),
1082 ),
1083 }
1084 })?;
1085 canonical_parent.starts_with(&canonical_project)
1086 } else {
1087 let normalized = self.project_path.join(requested);
1088 !normalized
1089 .components()
1090 .any(|c| c == std::path::Component::ParentDir)
1091 };
1092
1093 if !is_within_project {
1094 return Err(format_error_for_llm(
1095 "write_files",
1096 ErrorCategory::PathOutsideBoundary,
1097 &format!("Path '{}' is outside project boundary", requested.display()),
1098 Some(vec![
1099 "SECURITY: Writes are restricted to the project directory",
1100 "For temporary files, create a 'tmp/' directory in project root",
1101 "Use project-relative paths like 'tmp/output.txt'",
1102 &format!("Project root: {}", self.project_path.display()),
1103 ]),
1104 ));
1105 }
1106
1107 Ok(target)
1108 }
1109}
1110
1111impl Tool for WriteFilesTool {
1112 const NAME: &'static str = "write_files";
1113
1114 type Error = WriteFilesError;
1115 type Args = WriteFilesArgs;
1116 type Output = String;
1117
1118 async fn definition(&self, _prompt: String) -> ToolDefinition {
1119 ToolDefinition {
1120 name: Self::NAME.to_string(),
1121 description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
1122
1123**SECURITY: Path Restriction (Intentional)**
1124- ALL paths must be within the project directory
1125- Writing to /tmp, /etc, or any path outside the project is blocked
1126- This is a security feature to prevent unintended system modifications
1127- For temporary files, create a 'tmp/' directory within your project root
1128
1129**Atomicity:**
1130- All paths are validated BEFORE any files are written
1131- If any path is invalid, NO files are written
1132- Confirmation is requested for each file individually
1133
1134**USE THIS TOOL** (not just describe files) when the user asks for:
1135- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
1136- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
1137- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
1138- Multi-file docker-compose setups
1139- Any set of related files
1140
1141**DO NOT** just describe the files - actually call this tool to create them.
1142
1143Parent directories are created automatically."#.to_string(),
1144 parameters: json!({
1145 "type": "object",
1146 "properties": {
1147 "files": {
1148 "type": "array",
1149 "description": "List of files to write. All paths must be within project directory.",
1150 "items": {
1151 "type": "object",
1152 "properties": {
1153 "path": {
1154 "type": "string",
1155 "description": "Path to the file (relative to project root). Must be within project."
1156 },
1157 "content": {
1158 "type": "string",
1159 "description": "Content to write to the file"
1160 }
1161 },
1162 "required": ["path", "content"]
1163 }
1164 },
1165 "create_dirs": {
1166 "type": "boolean",
1167 "description": "If true (default), create parent directories if they don't exist"
1168 }
1169 },
1170 "required": ["files"]
1171 }),
1172 }
1173 }
1174
1175 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1176 let create_dirs = args.create_dirs.unwrap_or(true);
1177 let mut results = Vec::new();
1178 let mut total_bytes = 0usize;
1179 let mut total_lines = 0usize;
1180
1181 let mut validated_paths: Vec<(PathBuf, &FileToWrite)> = Vec::new();
1183 let mut invalid_paths: Vec<String> = Vec::new();
1184
1185 for file in &args.files {
1186 let requested_path = PathBuf::from(&file.path);
1187 match self.validate_path(&requested_path) {
1188 Ok(path) => validated_paths.push((path, file)),
1189 Err(_) => invalid_paths.push(file.path.clone()),
1190 }
1191 }
1192
1193 if !invalid_paths.is_empty() {
1195 let invalid_list = invalid_paths.join(", ");
1196 return Ok(format_error_for_llm(
1197 "write_files",
1198 ErrorCategory::PathOutsideBoundary,
1199 &format!("Invalid paths detected: {}", invalid_list),
1200 Some(vec![
1201 "SECURITY: All paths must be within the project directory",
1202 "None of the files were written due to invalid paths",
1203 "For temporary files, create a 'tmp/' directory in project root",
1204 &format!("Project root: {}", self.project_path.display()),
1205 ]),
1206 ));
1207 }
1208
1209 for (file_path, file) in validated_paths {
1211 let old_content = if file_path.exists() {
1213 fs::read_to_string(&file_path).ok()
1214 } else {
1215 None
1216 };
1217
1218 let filename = std::path::Path::new(&file.path)
1220 .file_name()
1221 .map(|n| n.to_string_lossy().to_string())
1222 .unwrap_or_else(|| file.path.clone());
1223
1224 let needs_confirmation =
1226 self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
1227
1228 if needs_confirmation {
1229 let confirmation = if let Some(ref client) = self.ide_client {
1231 let guard = client.lock().await;
1232 if guard.is_connected() {
1233 confirm_file_write_with_ide(
1234 &file.path,
1235 old_content.as_deref(),
1236 &file.content,
1237 Some(&*guard),
1238 )
1239 .await
1240 } else {
1241 drop(guard);
1242 confirm_file_write(&file.path, old_content.as_deref(), &file.content)
1243 }
1244 } else {
1245 confirm_file_write(&file.path, old_content.as_deref(), &file.content)
1246 };
1247
1248 match confirmation {
1249 ConfirmationResult::Proceed => {
1250 }
1252 ConfirmationResult::ProceedAlways(pattern) => {
1253 self.allowed_patterns.allow(pattern);
1254 }
1255 ConfirmationResult::Modify(feedback) => {
1256 return Ok(format_cancelled(
1258 &file.path,
1259 "User requested changes",
1260 Some(&feedback),
1261 ));
1262 }
1263 ConfirmationResult::Cancel => {
1264 return Ok(format_cancelled(
1266 &file.path,
1267 "User cancelled the operation",
1268 None,
1269 ));
1270 }
1271 }
1272 } else {
1273 use crate::agent::ui::diff::{render_diff, render_new_file};
1275 use colored::Colorize;
1276
1277 if let Some(old) = &old_content {
1278 render_diff(old, &file.content, &file.path);
1279 } else {
1280 render_new_file(&file.content, &file.path);
1281 }
1282 println!(" {} Auto-accepted", "✓".green());
1283 }
1284
1285 if create_dirs
1287 && let Some(parent) = file_path.parent()
1288 && !parent.exists()
1289 {
1290 fs::create_dir_all(parent).map_err(|e| {
1291 WriteFilesError(format!(
1292 "Failed to create directories for {}: {}",
1293 file.path, e
1294 ))
1295 })?;
1296 }
1297
1298 let file_existed = file_path.exists();
1299
1300 fs::write(&file_path, &file.content)
1301 .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
1302
1303 let lines = file.content.lines().count();
1304 total_bytes += file.content.len();
1305 total_lines += lines;
1306
1307 results.push(json!({
1308 "path": file.path,
1309 "action": if file_existed { "updated" } else { "created" },
1310 "lines": lines,
1311 "bytes": file.content.len()
1312 }));
1313 }
1314
1315 let result = json!({
1318 "success": true,
1319 "files_written": results.len(),
1320 "total_lines": total_lines,
1321 "total_bytes": total_bytes,
1322 "files": results
1323 });
1324
1325 serde_json::to_string_pretty(&result)
1326 .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use tempfile::tempdir;
1334
1335 #[test]
1340 fn test_is_likely_binary_text() {
1341 let text = b"fn main() {\n println!(\"Hello, world!\");\n}\n";
1343 assert!(!ReadFileTool::is_likely_binary(text));
1344 }
1345
1346 #[test]
1347 fn test_is_likely_binary_with_null() {
1348 let binary = b"some text\x00more text";
1350 assert!(ReadFileTool::is_likely_binary(binary));
1351 }
1352
1353 #[test]
1354 fn test_is_likely_binary_empty() {
1355 let empty: &[u8] = b"";
1357 assert!(!ReadFileTool::is_likely_binary(empty));
1358 }
1359
1360 #[test]
1361 fn test_is_likely_binary_utf8() {
1362 let utf8 = "日本語テキスト".as_bytes();
1364 assert!(!ReadFileTool::is_likely_binary(utf8));
1365 }
1366
1367 #[tokio::test]
1368 async fn test_read_file_within_project() {
1369 let dir = tempdir().unwrap();
1370 let file_path = dir.path().join("test.txt");
1371 fs::write(&file_path, "Hello, world!").unwrap();
1372
1373 let tool = ReadFileTool::new(dir.path().to_path_buf());
1374 let args = ReadFileArgs {
1375 path: "test.txt".to_string(),
1376 start_line: None,
1377 end_line: None,
1378 };
1379
1380 let result = tool.call(args).await.unwrap();
1381 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1382
1383 assert_eq!(parsed["file"], "test.txt");
1384 assert!(
1385 parsed["content"]
1386 .as_str()
1387 .unwrap()
1388 .contains("Hello, world!")
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn test_read_file_not_found() {
1394 let dir = tempdir().unwrap();
1395 let tool = ReadFileTool::new(dir.path().to_path_buf());
1396 let args = ReadFileArgs {
1397 path: "nonexistent.txt".to_string(),
1398 start_line: None,
1399 end_line: None,
1400 };
1401
1402 let result = tool.call(args).await.unwrap();
1403 assert!(
1405 result.contains("error")
1406 || result.contains("not found")
1407 || result.contains("does not exist")
1408 );
1409 }
1410
1411 #[tokio::test]
1416 async fn test_list_directory_basic() {
1417 let dir = tempdir().unwrap();
1418 fs::write(dir.path().join("file1.txt"), "content").unwrap();
1419 fs::write(dir.path().join("file2.txt"), "content").unwrap();
1420 fs::create_dir(dir.path().join("subdir")).unwrap();
1421
1422 let tool = ListDirectoryTool::new(dir.path().to_path_buf());
1423 let args = ListDirectoryArgs {
1424 path: Some(".".to_string()),
1425 recursive: None,
1426 };
1427
1428 let result = tool.call(args).await.unwrap();
1429 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1430
1431 assert!(parsed["entries"].as_array().unwrap().len() >= 2);
1432 }
1433}