1use mermaid_domain::ProgressEvent;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19
20use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21use mermaid_model::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25use super::path_safety::{
26 AllowedRoots, PathContainment, ResolvedInRoot, relative_within, resolve_in_roots,
27 resolve_path_within,
28};
29
30fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
34 ToolDefinition {
35 name: name.to_string(),
36 description: description.to_string(),
37 input_schema,
38 }
39}
40
41const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
47
48const READ_DEDUP_CAP: usize = 128;
52
53struct ReadDedupEntry {
56 scope: String,
57 path: String,
58 turn: u64,
59 hash: [u8; 32],
60 line_count: usize,
61}
62
63static READ_DEDUP: std::sync::OnceLock<
73 std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
74> = std::sync::OnceLock::new();
75
76fn read_dedup_scope(ctx: &ExecContext) -> String {
81 format!(
82 "{}|{}|{}",
83 ctx.session_id.as_deref().unwrap_or(""),
84 ctx.task_id.as_deref().unwrap_or(""),
85 ctx.workdir.display(),
86 )
87}
88
89fn duplicate_read_note(ctx: &ExecContext, path: &str, content: &str) -> Option<String> {
94 use sha2::{Digest, Sha256};
95 let hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
96 let line_count = content.lines().count();
97 let scope = read_dedup_scope(ctx);
98 let mut store = READ_DEDUP
99 .get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()))
100 .lock()
101 .unwrap_or_else(std::sync::PoisonError::into_inner);
102 if let Some(entry) = store
103 .iter_mut()
104 .find(|e| e.scope == scope && e.path == path)
105 {
106 let identical_this_turn = entry.turn == ctx.turn.0 && entry.hash == hash;
107 entry.turn = ctx.turn.0;
108 entry.hash = hash;
109 entry.line_count = line_count;
110 return identical_this_turn.then(|| {
111 format!(
112 "{path}: unchanged since your read earlier this turn — the full \
113 content ({line_count} lines) is already in this turn's tool \
114 results; reuse it. A read after the file changes, or in a later \
115 turn, returns the full content again."
116 )
117 });
118 }
119 store.push_back(ReadDedupEntry {
120 scope,
121 path: path.to_string(),
122 turn: ctx.turn.0,
123 hash,
124 line_count,
125 });
126 if store.len() > READ_DEDUP_CAP {
127 store.pop_front();
128 }
129 None
130}
131
132pub struct ReadFileTool;
135
136#[async_trait]
137impl ToolExecutor for ReadFileTool {
138 fn name(&self) -> &'static str {
139 "read_file"
140 }
141
142 fn schema(&self) -> ToolDefinition {
143 defn(
144 "read_file",
145 "Read the contents of one or more files from disk. Relative paths resolve relative to the project directory; absolute paths may resolve anywhere on disk.",
146 serde_json::json!({
147 "type": "object",
148 "properties": {
149 "path": { "type": "string", "description": "File to read (single)." },
150 "paths": {
151 "type": "array",
152 "items": { "type": "string" },
153 "description": "Multiple files to read sequentially, in order."
154 }
155 },
156 "oneOf": [
157 { "required": ["path"] },
158 { "required": ["paths"] }
159 ]
160 }),
161 )
162 }
163
164 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
165 let paths = match extract_paths(&args) {
166 Ok(p) => p,
167 Err(e) => return ToolOutcome::error(e, None),
168 };
169 if paths.is_empty() {
170 return ToolOutcome::error("read_file requires at least one path", None);
171 }
172
173 let start = std::time::Instant::now();
174 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
175 let mut combined = String::new();
176 let mut any_truncated = false;
177
178 for (idx, raw_path) in paths.iter().enumerate() {
179 let target = match resolve_read_target(&roots, raw_path) {
180 Ok(target) => target,
181 Err(e) => {
182 return ToolOutcome::error(
183 format!("{raw_path}: {e}"),
184 Some(start.elapsed().as_secs_f64()),
185 );
186 },
187 };
188 if let Some(abs) = &target.external
192 && let Some(blocked) = external_read_gate(&ctx, raw_path, abs).await
193 {
194 return blocked;
195 }
196 tokio::select! {
199 biased;
200 _ = ctx.token.cancelled() => {
201 return ToolOutcome::cancelled();
202 },
203 read = read_one(target.root, target.rel) => {
204 match read {
205 Ok((content, was_truncated)) => {
206 let (content, was_truncated) =
212 duplicate_read_note(&ctx, raw_path, &content)
213 .map_or((content, was_truncated), |note| (note, false));
214 any_truncated |= was_truncated;
215 if paths.len() > 1 {
216 let _ = ctx.progress.send(ProgressEvent::Status(
217 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
218 )).await;
219 combined.push_str(&format!(
220 "=== {raw_path} ===\n{content}\n\n"
221 ));
222 } else {
223 combined = content;
224 }
225 },
226 Err(e) => {
227 return ToolOutcome::error(
228 format!("{raw_path}: {e}"),
229 Some(start.elapsed().as_secs_f64()),
230 );
231 },
232 }
233 },
234 }
235 }
236
237 if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
243 combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
244 any_truncated = true;
245 }
246
247 let duration_secs = start.elapsed().as_secs_f64();
248 let line_count = combined.lines().count();
249 let byte_count = combined.len();
250 let truncated = any_truncated;
254 ToolOutcome::success(
255 combined,
256 format!(
257 "{} {} read",
258 line_count,
259 plural(line_count, "line", "lines")
260 ),
261 duration_secs,
262 )
263 .with_metadata(ToolRunMetadata {
264 detail: ToolMetadata::ReadFile {
265 paths,
266 line_count,
267 byte_count,
268 truncated,
269 },
270 line_count: Some(line_count),
271 byte_count: Some(byte_count),
272 ..ToolRunMetadata::default()
273 })
274 }
275}
276
277pub struct DeleteFileTool;
281
282#[async_trait]
283impl ToolExecutor for DeleteFileTool {
284 fn name(&self) -> &'static str {
285 "delete_file"
286 }
287
288 fn schema(&self) -> ToolDefinition {
289 defn(
290 "delete_file",
291 "Remove a file from disk. Paths may be relative to the project directory or absolute paths on disk. Fails on directories — use `execute_command rm -rf` for those.",
292 serde_json::json!({
293 "type": "object",
294 "properties": { "path": { "type": "string" } },
295 "required": ["path"]
296 }),
297 )
298 }
299
300 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
301 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
302 return err("delete_file requires 'path'", 0.0);
303 };
304 let start = std::time::Instant::now();
305 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
306 let ResolvedInRoot {
307 abs,
308 rel,
309 root,
310 containment,
311 } = match resolve_in_roots(&roots, raw_path) {
312 Ok(r) => r,
313 Err(e) => return err(&format!("delete_file: {e}"), 0.0),
314 };
315 let pending_action = serde_json::json!({
316 "tool": "delete_file",
317 "args": { "path": raw_path },
318 "workdir": ctx.workdir.display().to_string(),
319 "turn_id": ctx.turn.0,
320 "call_id": ctx.call_id.0,
321 "task_id": ctx.task_id.clone(),
322 });
323 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
324 &ctx,
325 "delete_file",
326 raw_path,
327 std::slice::from_ref(&abs),
328 pending_action,
329 containment,
330 )
331 .await
332 {
333 return *outcome;
334 }
335 let _write_guard = tokio::select! {
340 biased;
341 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
342 g = super::path_lock::lock_path(&abs) => g,
343 };
344 if ctx.config.safety.checkpoint_on_mutation
347 && containment != PathContainment::Scratchpad
348 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
349 &ctx.workdir,
350 std::slice::from_ref(&abs),
351 Some(serde_json::json!({
352 "tool": "delete_file",
353 "path": raw_path,
354 })),
355 ctx.checkpoint_origin(),
356 )
357 {
358 return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
359 }
360 let display = raw_path.to_string();
361
362 tokio::select! {
363 biased;
364 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
365 result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
366 match result {
367 Ok(Ok(())) => {
368 let duration_secs = start.elapsed().as_secs_f64();
369 after_file_mutation(&ctx, "delete_file", &display);
370 ToolOutcome::success(
371 format!("Deleted {display}"),
372 "file deleted",
373 duration_secs,
374 )
375 .with_metadata(ToolRunMetadata {
376 detail: ToolMetadata::DeleteFile { path: display },
377 ..ToolRunMetadata::default()
378 })
379 },
380 Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
381 start.elapsed().as_secs_f64()),
382 Err(e) => err(&format!("delete_file join error: {e}"),
383 start.elapsed().as_secs_f64()),
384 }
385 }
386 }
387 }
388}
389
390pub struct CreateDirectoryTool;
392
393#[async_trait]
394impl ToolExecutor for CreateDirectoryTool {
395 fn name(&self) -> &'static str {
396 "create_directory"
397 }
398
399 fn schema(&self) -> ToolDefinition {
400 defn(
401 "create_directory",
402 "Create a directory (and any missing parents) at the given path. Paths may be relative to the project directory or absolute paths on disk.",
403 serde_json::json!({
404 "type": "object",
405 "properties": { "path": { "type": "string" } },
406 "required": ["path"]
407 }),
408 )
409 }
410
411 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
412 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
413 return err("create_directory requires 'path'", 0.0);
414 };
415 let start = std::time::Instant::now();
416 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
417 let ResolvedInRoot {
418 abs,
419 rel,
420 root,
421 containment,
422 } = match resolve_in_roots(&roots, raw_path) {
423 Ok(r) => r,
424 Err(e) => return err(&format!("create_directory: {e}"), 0.0),
425 };
426 let pending_action = serde_json::json!({
427 "tool": "create_directory",
428 "args": { "path": raw_path },
429 "workdir": ctx.workdir.display().to_string(),
430 "turn_id": ctx.turn.0,
431 "call_id": ctx.call_id.0,
432 "task_id": ctx.task_id.clone(),
433 });
434 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
435 &ctx,
436 "create_directory",
437 raw_path,
438 std::slice::from_ref(&abs),
439 pending_action,
440 containment,
441 )
442 .await
443 {
444 return *outcome;
445 }
446 let _write_guard = tokio::select! {
449 biased;
450 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
451 g = super::path_lock::lock_path(&abs) => g,
452 };
453 if ctx.config.safety.checkpoint_on_mutation
455 && containment != PathContainment::Scratchpad
456 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
457 &ctx.workdir,
458 std::slice::from_ref(&abs),
459 Some(serde_json::json!({
460 "tool": "create_directory",
461 "path": raw_path,
462 })),
463 ctx.checkpoint_origin(),
464 )
465 {
466 return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
467 }
468 let display = raw_path.to_string();
469
470 tokio::select! {
471 biased;
472 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
473 result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
474 match result {
475 Ok(Ok(())) => {
476 let duration_secs = start.elapsed().as_secs_f64();
477 after_file_mutation(&ctx, "create_directory", &display);
478 ToolOutcome::success(
479 format!("Created directory {display}"),
480 "directory created",
481 duration_secs,
482 )
483 .with_metadata(ToolRunMetadata {
484 detail: ToolMetadata::CreateDirectory { path: display },
485 ..ToolRunMetadata::default()
486 })
487 },
488 Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
489 start.elapsed().as_secs_f64()),
490 Err(e) => err(&format!("create_directory join error: {e}"),
491 start.elapsed().as_secs_f64()),
492 }
493 }
494 }
495 }
496}
497
498pub struct WriteFileTool;
500
501#[async_trait]
502impl ToolExecutor for WriteFileTool {
503 fn name(&self) -> &'static str {
504 "write_file"
505 }
506
507 fn schema(&self) -> ToolDefinition {
508 defn(
509 "write_file",
510 "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths may be relative to the project directory or absolute. Prefer `apply_patch` for small targeted changes.",
511 serde_json::json!({
512 "type": "object",
513 "properties": {
514 "path": { "type": "string" },
515 "content": { "type": "string" }
516 },
517 "required": ["path", "content"]
518 }),
519 )
520 }
521
522 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
523 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
524 return ToolOutcome::error("write_file requires 'path' (string)", None);
525 };
526 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
527 return ToolOutcome::error("write_file requires 'content' (string)", None);
528 };
529
530 let start = std::time::Instant::now();
531 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
532 let ResolvedInRoot {
535 abs: abs_path,
536 rel,
537 root,
538 containment,
539 } = match resolve_in_roots(&roots, path) {
540 Ok(r) => r,
541 Err(e) => return ToolOutcome::error(format!("write_file: {e}"), None),
542 };
543 let pending_action = serde_json::json!({
544 "tool": "write_file",
545 "args": { "path": path, "content": content },
546 "workdir": ctx.workdir.display().to_string(),
547 "turn_id": ctx.turn.0,
548 "call_id": ctx.call_id.0,
549 "task_id": ctx.task_id.clone(),
550 });
551 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
552 &ctx,
553 "write_file",
554 path,
555 std::slice::from_ref(&abs_path),
556 pending_action,
557 containment,
558 )
559 .await
560 {
561 return *outcome;
562 }
563 let _write_guard = tokio::select! {
568 biased;
569 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
570 g = super::path_lock::lock_path(&abs_path) => g,
571 };
572 if ctx.config.safety.checkpoint_on_mutation
574 && containment != PathContainment::Scratchpad
575 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
576 &ctx.workdir,
577 std::slice::from_ref(&abs_path),
578 Some(serde_json::json!({
579 "tool": "write_file",
580 "path": path,
581 })),
582 ctx.checkpoint_origin(),
583 )
584 {
585 return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), None);
586 }
587 let display_path = path.to_string();
588 let line_count = content.lines().count();
589 let byte_count = content.len();
590 let content = content.to_string();
591
592 tokio::select! {
593 biased;
594 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
595 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
599 match result {
600 Ok(Ok(write)) => {
601 let duration_secs = start.elapsed().as_secs_f64();
602 after_file_mutation(&ctx, "write_file", &display_path);
603 ToolOutcome::success(
604 format!("Wrote {} ({} lines)", display_path, write.line_count),
605 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
606 duration_secs,
607 )
608 .with_metadata(ToolRunMetadata {
609 detail: ToolMetadata::WriteFile {
610 path: display_path,
611 line_count,
612 byte_count,
613 created: Some(write.created),
614 },
615 line_count: Some(line_count),
616 byte_count: Some(byte_count),
617 display_diff: Some(write.diff.display_diff),
618 diff_truncated: write.diff.truncated,
619 lines_added: write.diff.added,
620 lines_removed: write.diff.removed,
621 ..ToolRunMetadata::default()
622 })
623 },
624 Ok(Err(e)) => ToolOutcome::error(
625 format!("write_file({display_path}): {e}"),
626 Some(start.elapsed().as_secs_f64()),
627 ),
628 Err(e) => ToolOutcome::error(
629 format!("write_file join error: {e}"),
630 Some(start.elapsed().as_secs_f64()),
631 ),
632 }
633 }
634 }
635 }
636}
637
638pub struct EditFileTool;
640
641#[async_trait]
642impl ToolExecutor for EditFileTool {
643 fn name(&self) -> &'static str {
644 "edit_file"
645 }
646
647 fn schema(&self) -> ToolDefinition {
648 defn(
649 "edit_file",
650 "Perform a precise search-and-replace edit on an existing file. Replaces `target_content` with `replacement_content`. Fails if `target_content` is not found or matches multiple locations (unless `allow_multiple` is true). Matching tolerates minor whitespace and quotation drift. Paths may be relative to the project directory or absolute.",
651 serde_json::json!({
652 "type": "object",
653 "properties": {
654 "path": {
655 "type": "string",
656 "description": "Path to the file to edit."
657 },
658 "target_content": {
659 "type": "string",
660 "description": "The exact or near-exact block of text to replace. Must match uniquely in the file unless allow_multiple is true."
661 },
662 "replacement_content": {
663 "type": "string",
664 "description": "The new replacement text."
665 },
666 "allow_multiple": {
667 "type": "boolean",
668 "description": "If true, replace all occurrences of target_content instead of requiring uniqueness. Defaults to false."
669 }
670 },
671 "required": ["path", "target_content", "replacement_content"]
672 }),
673 )
674 }
675
676 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
677 let start = std::time::Instant::now();
678 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
679 return ToolOutcome::error("edit_file requires 'path' (string)", None);
680 };
681 let Some(target) = args.get("target_content").and_then(|v| v.as_str()) else {
682 return ToolOutcome::error("edit_file requires 'target_content' (string)", None);
683 };
684 let Some(replacement) = args.get("replacement_content").and_then(|v| v.as_str()) else {
685 return ToolOutcome::error("edit_file requires 'replacement_content' (string)", None);
686 };
687 let allow_multiple = args
688 .get("allow_multiple")
689 .and_then(serde_json::Value::as_bool)
690 .unwrap_or(false);
691
692 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
693 let ResolvedInRoot {
694 abs: abs_path,
695 rel,
696 root,
697 containment,
698 } = match resolve_in_roots(&roots, path) {
699 Ok(r) => r,
700 Err(e) => return ToolOutcome::error(format!("edit_file: {e}"), None),
701 };
702
703 let pending_action = serde_json::json!({
704 "tool": "edit_file",
705 "args": {
706 "path": path,
707 "target_content": target,
708 "replacement_content": replacement,
709 "allow_multiple": allow_multiple,
710 },
711 "workdir": ctx.workdir.display().to_string(),
712 "turn_id": ctx.turn.0,
713 "call_id": ctx.call_id.0,
714 "task_id": ctx.task_id.clone(),
715 });
716 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
717 &ctx,
718 "edit_file",
719 path,
720 std::slice::from_ref(&abs_path),
721 pending_action,
722 containment,
723 )
724 .await
725 {
726 return *outcome;
727 }
728
729 let _write_guard = tokio::select! {
730 biased;
731 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
732 g = super::path_lock::lock_path(&abs_path) => g,
733 };
734
735 if ctx.config.safety.checkpoint_on_mutation
736 && containment != PathContainment::Scratchpad
737 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
738 &ctx.workdir,
739 std::slice::from_ref(&abs_path),
740 Some(serde_json::json!({
741 "tool": "edit_file",
742 "path": path,
743 })),
744 ctx.checkpoint_origin(),
745 )
746 {
747 return ToolOutcome::error(format!("edit_file checkpoint failed: {e}"), None);
748 }
749
750 let display_path = path.to_string();
751 let target = target.to_string();
752 let replacement = replacement.to_string();
753
754 tokio::select! {
755 biased;
756 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
757 result = tokio::task::spawn_blocking(move || edit_file_blocking(&root, &rel, &target, &replacement, allow_multiple)) => {
758 match result {
759 Ok(Ok(edit)) => {
760 let duration_secs = start.elapsed().as_secs_f64();
761 after_file_mutation(&ctx, "edit_file", &display_path);
762 edit_success_outcome(&display_path, edit, duration_secs)
763 },
764 Ok(Err(e)) => ToolOutcome::error(
765 format!("edit_file({display_path}): {e}"),
766 Some(start.elapsed().as_secs_f64()),
767 ),
768 Err(e) => ToolOutcome::error(
769 format!("edit_file join error: {e}"),
770 Some(start.elapsed().as_secs_f64()),
771 ),
772 }
773 }
774 }
775 }
776}
777
778fn edit_success_outcome(display_path: &str, edit: EditResult, duration_secs: f64) -> ToolOutcome {
779 let fuzzy_note = if edit.fuzzy {
780 "\nnote: matched with fuzzy (whitespace/Unicode) context; verify the result."
781 } else {
782 ""
783 };
784 ToolOutcome::success(
785 format!("Edited {display_path}{fuzzy_note}"),
786 diff_summary(edit.diff.added, edit.diff.removed, duration_secs),
787 duration_secs,
788 )
789 .with_metadata(ToolRunMetadata {
790 detail: ToolMetadata::ApplyPatch {
791 added: Vec::new(),
792 modified: vec![display_path.to_string()],
793 deleted: Vec::new(),
794 renamed: Vec::new(),
795 fuzzy: edit.fuzzy,
796 },
797 display_diff: Some(edit.diff.display_diff),
798 diff_truncated: edit.diff.truncated,
799 lines_added: edit.diff.added,
800 lines_removed: edit.diff.removed,
801 ..ToolRunMetadata::default()
802 })
803}
804
805fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
808 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
810 reject_web_url(p)?;
811 return Ok(vec![p.to_string()]);
812 }
813 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
814 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
815 return Err(format!(
816 "read_file: too many paths ({}); cap is {} per call — split the request",
817 arr.len(),
818 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
819 ));
820 }
821 let mut out = Vec::with_capacity(arr.len());
822 for v in arr {
823 let Some(s) = v.as_str() else {
824 return Err("read_file 'paths' must be an array of strings".to_string());
825 };
826 reject_web_url(s)?;
827 out.push(s.to_string());
828 }
829 return Ok(out);
830 }
831 Err("read_file requires 'path' or 'paths'".to_string())
832}
833
834fn reject_web_url(path: &str) -> Result<(), String> {
840 let head: String = path
841 .trim_start()
842 .chars()
843 .take(8)
844 .collect::<String>()
845 .to_ascii_lowercase();
846 if head.starts_with("http://") || head.starts_with("https://") {
847 return Err(format!(
848 "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
849 ));
850 }
851 Ok(())
852}
853
854fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
862 if !Path::new(raw).is_absolute() {
863 return None;
864 }
865 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
866 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
867 && let Ok(rel) = relative_within(&root, raw)
868 {
869 return Some((root, rel));
870 }
871 }
872 None
873}
874
875struct ReadTarget {
878 root: PathBuf,
879 rel: PathBuf,
880 external: Option<PathBuf>,
883}
884
885fn resolve_read_target(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<ReadTarget> {
894 match resolve_in_roots(roots, raw) {
895 Ok(ResolvedInRoot {
896 abs,
897 rel,
898 root,
899 containment,
900 }) => match containment {
901 PathContainment::Project | PathContainment::Scratchpad => Ok(ReadTarget {
902 root,
903 rel,
904 external: None,
905 }),
906 PathContainment::External => Ok(resolve_in_memory_roots(roots.workdir, raw).map_or(
907 ReadTarget {
908 root,
909 rel,
910 external: Some(abs),
911 },
912 |(root, rel)| ReadTarget {
913 root,
914 rel,
915 external: None,
916 },
917 )),
918 },
919 Err(msg) => {
920 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
921 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
922 Ok(ReadTarget {
923 root,
924 rel,
925 external: None,
926 })
927 },
928 }
929}
930
931async fn external_read_gate(ctx: &ExecContext, raw: &str, abs: &Path) -> Option<ToolOutcome> {
941 let mut request = mermaid_runtime::ActionRequest::new(
942 "read_file",
943 mermaid_runtime::ToolCategory::ExternalDirectory,
944 format!("read_file {raw}"),
945 );
946 request.path = Some(abs.display().to_string());
947 let pending_action = serde_json::json!({
948 "tool": "read_file",
949 "args": { "path": raw },
950 "workdir": ctx.workdir.display().to_string(),
951 "turn_id": ctx.turn.0,
952 "call_id": ctx.call_id.0,
953 "task_id": ctx.task_id.clone(),
954 });
955 match super::policy_gate::gate(ctx, request, &[], pending_action, false, false).await {
956 super::policy_gate::Gate::Block(outcome) => Some(outcome),
957 super::policy_gate::Gate::Proceed { .. } => None,
958 }
959}
960
961async fn read_one(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool)> {
971 let result = tokio::task::spawn_blocking(move || {
972 let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
973 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
977 let mut s = String::from_utf8_lossy(&data).into_owned();
978 if truncated {
979 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
981 s.truncate(cut);
982 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
983 }
984 Ok::<_, std::io::Error>((s, truncated))
985 })
986 .await
987 .map_err(|e| std::io::Error::other(e.to_string()))??;
988 Ok(result)
989}
990
991fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
1000 if let Some(parent) = rel.parent()
1001 && !parent.as_os_str().is_empty()
1002 {
1003 mermaid_runtime::create_dir_all_beneath(root, parent)?;
1004 }
1005 mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
1006 Ok(content.lines().count())
1007}
1008
1009struct WriteResult {
1010 line_count: usize,
1011 created: bool,
1012 diff: mermaid_model::diff::DisplayDiff,
1013}
1014
1015fn write_with_diff_blocking(
1022 root: &Path,
1023 abs_path: &Path,
1024 rel: &Path,
1025 content: &str,
1026) -> std::io::Result<WriteResult> {
1027 let (old_content, created, elide_diff) =
1028 match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
1029 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
1030 Ok((_, true)) => (String::new(), false, true),
1032 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1034 Err(_) => (String::new(), false, true),
1036 };
1037 let diff = if elide_diff {
1038 mermaid_model::diff::DisplayDiff {
1039 display_diff: format!(
1040 "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
1041 ),
1042 added: 0,
1043 removed: 0,
1044 truncated: true,
1045 }
1046 } else {
1047 mermaid_model::diff::generate_display_diff(&old_content, content)
1048 };
1049 let line_count = write_one_blocking(root, rel, content)?;
1050 Ok(WriteResult {
1051 line_count,
1052 created,
1053 diff,
1054 })
1055}
1056
1057struct EditResult {
1058 diff: mermaid_model::diff::DisplayDiff,
1059 fuzzy: bool,
1060}
1061
1062fn edit_file_blocking(
1063 root: &Path,
1064 rel: &Path,
1065 target: &str,
1066 replacement: &str,
1067 allow_multiple: bool,
1068) -> Result<EditResult, String> {
1069 let file = mermaid_runtime::open_beneath(root, rel, mermaid_runtime::OpenIntent::Read)
1070 .map_err(|e| format!("cannot open file: {e}"))?;
1071 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)
1072 .map_err(|e| format!("cannot read file: {e}"))?;
1073 if truncated {
1074 return Err(format!(
1075 "file exceeds maximum size limit ({MAX_FILE_READ_BYTES} bytes)"
1076 ));
1077 }
1078 let original = String::from_utf8_lossy(&data).into_owned();
1079 let applied = mermaid_runtime::replace_content(&original, target, replacement, allow_multiple)
1080 .map_err(|e| e.to_string())?;
1081
1082 write_one_blocking(root, rel, &applied.new_contents)
1083 .map_err(|e| format!("cannot write file: {e}"))?;
1084
1085 let diff = mermaid_model::diff::generate_display_diff(&original, &applied.new_contents);
1086 Ok(EditResult {
1087 diff,
1088 fuzzy: applied.fuzzy,
1089 })
1090}
1091
1092pub(super) enum MutationGate {
1094 Blocked(Box<ToolOutcome>),
1096 Proceed,
1097}
1098
1099pub(super) async fn mutation_policy_outcome(
1114 ctx: &ExecContext,
1115 tool: &str,
1116 path: &str,
1117 checkpoint_paths: &[PathBuf],
1118 pending_action: serde_json::Value,
1119 containment: PathContainment,
1120) -> MutationGate {
1121 let category = match containment {
1122 PathContainment::Project | PathContainment::Scratchpad => {
1123 mermaid_runtime::ToolCategory::Edit
1124 },
1125 PathContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
1126 };
1127 let mut request = mermaid_runtime::ActionRequest::new(tool, category, format!("{tool} {path}"));
1128 request.path = Some(path.to_string());
1129 match super::policy_gate::gate(
1132 ctx,
1133 request,
1134 checkpoint_paths,
1135 pending_action,
1136 true,
1137 containment == PathContainment::Scratchpad,
1138 )
1139 .await
1140 {
1141 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
1142 super::policy_gate::Gate::Proceed { .. } => {
1143 let _ = mermaid_runtime::run_plugin_hooks(
1144 "before_file_mutation",
1145 &serde_json::json!({
1146 "task_id": ctx.task_id.clone(),
1147 "turn_id": ctx.turn.0,
1148 "call_id": ctx.call_id.0,
1149 "tool": tool,
1150 "path": path,
1151 }),
1152 );
1153 MutationGate::Proceed
1154 },
1155 }
1156}
1157
1158pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
1159 let _ = mermaid_runtime::run_plugin_hooks(
1160 "after_file_mutation",
1161 &serde_json::json!({
1162 "task_id": ctx.task_id.clone(),
1163 "turn_id": ctx.turn.0,
1164 "call_id": ctx.call_id.0,
1165 "tool": tool,
1166 "path": path,
1167 }),
1168 );
1169}
1170
1171fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
1172 ToolOutcome::error(msg, Some(duration_secs))
1173}
1174
1175fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
1176 if count == 1 { singular } else { plural }
1177}
1178
1179pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
1180 format!(
1181 "+{} -{}, took {}",
1182 added,
1183 removed,
1184 format_duration_for_diff(duration_secs)
1185 )
1186}
1187
1188fn format_duration_for_diff(seconds: f64) -> String {
1189 if seconds < 1.0 {
1190 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1191 } else if seconds < 10.0 {
1192 format!("{seconds:.1}s")
1193 } else {
1194 format!("{}s", seconds.round() as u64)
1195 }
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use super::*;
1201 use crate::providers::ctx::test_exec_context;
1202 use mermaid_domain::{ToolCallId, TurnId};
1203 use std::fs;
1204
1205 #[tokio::test]
1211 async fn read_file_resolves_memory_roots_read_only() {
1212 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
1213 let _ = fs::remove_dir_all(&base);
1214 let repo = base.join("repo");
1215 let workdir = repo.join("src");
1216 fs::create_dir_all(&workdir).unwrap();
1217 fs::create_dir_all(repo.join(".git")).unwrap();
1218 let mem_dir = repo.join(".mermaid").join("memory");
1219 fs::create_dir_all(&mem_dir).unwrap();
1220 let fact = mem_dir.join("fact.md");
1221 fs::write(&fact, "the fact body").unwrap();
1222
1223 let roots = AllowedRoots::new(&workdir, None);
1224 let target = resolve_read_target(&roots, fact.to_str().unwrap()).unwrap();
1227 assert!(target.external.is_none(), "memory reads are not external");
1228 let (content, truncated) = read_one(target.root, target.rel).await.unwrap();
1229 assert!(!truncated);
1230 assert_eq!(content, "the fact body");
1231
1232 let stray = base.join("stray.txt");
1235 fs::write(&stray, "nope").unwrap();
1236 let target = resolve_read_target(&roots, stray.to_str().unwrap()).unwrap();
1237 assert_eq!(
1238 target.external.as_deref().map(|p| p.ends_with("stray.txt")),
1239 Some(true),
1240 "a path outside every root is external"
1241 );
1242
1243 let _ = fs::remove_dir_all(&base);
1244 }
1245
1246 #[test]
1247 fn resolve_in_roots_contains_to_workdir() {
1248 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
1249 let _ = fs::remove_dir_all(&root);
1250 fs::create_dir_all(root.join("sub")).unwrap();
1251 let roots = AllowedRoots::new(&root, None);
1252
1253 assert!(resolve_in_roots(&roots, "sub").is_ok());
1255 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
1256 let canon_root = fs::canonicalize(&root).unwrap();
1257 assert!(resolved.abs.starts_with(&canon_root));
1258
1259 assert!(resolve_in_roots(&roots, "../escape.txt").is_ok());
1261 let outside = std::env::temp_dir().join("definitely_outside.txt");
1262 let r = resolve_in_roots(&roots, &outside.display().to_string()).unwrap();
1263 assert!(r.abs.ends_with(Path::new("definitely_outside.txt")));
1264
1265 let _ = fs::remove_dir_all(&root);
1266 }
1267
1268 fn temp_root(name: &str) -> PathBuf {
1269 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
1270 let _ = fs::remove_dir_all(&p);
1271 fs::create_dir_all(&p).expect("create tmpdir");
1272 p
1273 }
1274
1275 #[tokio::test]
1276 async fn read_file_returns_content() {
1277 let dir = temp_root("read_ok");
1278 fs::write(dir.join("a.txt"), "hello").expect("write");
1279 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1280
1281 let tool = ReadFileTool;
1282 let outcome = tool
1283 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1284 .await;
1285 assert!(outcome.is_success(), "expected success: {outcome:?}");
1286 assert_eq!(outcome.output(), "hello");
1287 let _ = fs::remove_dir_all(&dir);
1288 }
1289
1290 #[tokio::test]
1291 async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
1292 let dir = temp_root("read_url");
1297 fs::write(dir.join("a.txt"), "hello").expect("write");
1298 for args in [
1299 serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1300 serde_json::json!({"path": "HTTP://example.com/x"}),
1301 serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1302 ] {
1303 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1304 let outcome = ReadFileTool.execute(args, ctx).await;
1305 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1306 let msg = outcome.error_message().unwrap_or_default();
1307 assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1308 }
1309 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1310 let outcome = ReadFileTool
1311 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1312 .await;
1313 assert!(outcome.is_success(), "plain local reads must still work");
1314 let _ = fs::remove_dir_all(&dir);
1315 }
1316
1317 #[tokio::test]
1318 async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1319 let dir = temp_root("read_dedup");
1320 fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1321
1322 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1324 let outcome = ReadFileTool
1325 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1326 .await;
1327 assert_eq!(outcome.output(), "line one\nline two");
1328
1329 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1332 let outcome = ReadFileTool
1333 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1334 .await;
1335 assert!(outcome.is_success());
1336 assert!(
1337 outcome
1338 .output()
1339 .contains("unchanged since your read earlier this turn"),
1340 "{}",
1341 outcome.output()
1342 );
1343 assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1344 assert!(
1345 !outcome.output().contains("line two"),
1346 "the body must not repeat: {}",
1347 outcome.output()
1348 );
1349
1350 fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1356 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1357 let outcome = ReadFileTool
1358 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1359 .await;
1360 assert_eq!(
1361 outcome.output(),
1362 "line one\nline two\nline three",
1363 "a changed file must read in full"
1364 );
1365
1366 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1368 let outcome = ReadFileTool
1369 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1370 .await;
1371 assert!(
1372 outcome.output().contains("unchanged since"),
1373 "{}",
1374 outcome.output()
1375 );
1376
1377 let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1381 let outcome = ReadFileTool
1382 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1383 .await;
1384 assert_eq!(outcome.output(), "line one\nline two\nline three");
1385
1386 let _ = fs::remove_dir_all(&dir);
1387 }
1388
1389 #[tokio::test]
1390 async fn read_file_missing_path_errors() {
1391 let dir = temp_root("read_missing_path");
1392 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1393 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1394 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1395 let _ = fs::remove_dir_all(&dir);
1396 }
1397
1398 #[tokio::test]
1399 async fn read_file_nonexistent_errors() {
1400 let dir = temp_root("read_nonex");
1401 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1402 let outcome = ReadFileTool
1403 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1404 .await;
1405 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1406 let _ = fs::remove_dir_all(&dir);
1407 }
1408
1409 #[tokio::test]
1410 async fn read_file_with_multiple_paths_joins_contents() {
1411 let dir = temp_root("read_multi");
1412 fs::write(dir.join("a.txt"), "alpha").expect("write");
1413 fs::write(dir.join("b.txt"), "beta").expect("write");
1414 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1415 let outcome = ReadFileTool
1416 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1417 .await;
1418 assert!(outcome.is_success(), "expected success: {outcome:?}");
1419 let output = outcome.output();
1420 assert!(output.contains("=== a.txt ==="));
1421 assert!(output.contains("alpha"));
1422 assert!(output.contains("=== b.txt ==="));
1423 assert!(output.contains("beta"));
1424 let _ = fs::remove_dir_all(&dir);
1425 }
1426
1427 #[tokio::test]
1428 async fn read_file_multi_aggregate_is_capped() {
1429 let dir = temp_root("read_aggregate_cap");
1432 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1433 fs::write(dir.join("a.txt"), &chunk).expect("write a");
1434 fs::write(dir.join("b.txt"), &chunk).expect("write b");
1435 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1436 let outcome = ReadFileTool
1437 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1438 .await;
1439 assert!(outcome.is_success(), "expected success: {outcome:?}");
1440 let output = outcome.output();
1441 assert!(
1442 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1443 "combined must be capped, got {} bytes",
1444 output.len()
1445 );
1446 assert!(
1447 output.contains("elided"),
1448 "expected aggregate head+tail elision marker"
1449 );
1450 match &outcome.metadata.detail {
1451 ToolMetadata::ReadFile { truncated, .. } => {
1452 assert!(*truncated, "aggregate truncation must set truncated")
1453 },
1454 other => panic!("expected ReadFile metadata, got {other:?}"),
1455 }
1456 let _ = fs::remove_dir_all(&dir);
1457 }
1458
1459 #[tokio::test]
1460 async fn write_file_elides_diff_for_oversized_existing_file() {
1461 let dir = temp_root("write_oversized_diff");
1464 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1465 fs::write(dir.join("big.txt"), &big).expect("write fixture");
1466 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1467 let outcome = WriteFileTool
1468 .execute(
1469 serde_json::json!({"path": "big.txt", "content": "small\n"}),
1470 ctx,
1471 )
1472 .await;
1473 assert!(outcome.is_success(), "expected success: {outcome:?}");
1474 let diff = outcome
1475 .metadata
1476 .display_diff
1477 .as_deref()
1478 .expect("display diff");
1479 assert!(
1480 diff.contains("diff preview skipped"),
1481 "expected elision marker, got: {diff}"
1482 );
1483 assert!(
1484 outcome.metadata.diff_truncated,
1485 "oversized diff must set diff_truncated"
1486 );
1487 match &outcome.metadata.detail {
1488 ToolMetadata::WriteFile { created, .. } => {
1489 assert_eq!(*created, Some(false), "existing file is not 'created'")
1490 },
1491 other => panic!("expected WriteFile metadata, got {other:?}"),
1492 }
1493 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1495 assert_eq!(written, "small\n");
1496 let _ = fs::remove_dir_all(&dir);
1497 }
1498
1499 #[tokio::test]
1500 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1501 let dir = temp_root("read_marker_content");
1505 fs::write(
1506 dir.join("a.txt"),
1507 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1508 )
1509 .expect("write");
1510 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1511
1512 let outcome = ReadFileTool
1513 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1514 .await;
1515 assert!(outcome.is_success(), "expected success: {outcome:?}");
1516 match &outcome.metadata.detail {
1517 ToolMetadata::ReadFile { truncated, .. } => assert!(
1518 !truncated,
1519 "a file whose content contains the marker must not be flagged truncated"
1520 ),
1521 other => panic!("expected ReadFile metadata, got {other:?}"),
1522 }
1523 let _ = fs::remove_dir_all(&dir);
1524 }
1525
1526 #[tokio::test]
1527 async fn read_file_respects_cancellation() {
1528 let dir = temp_root("read_cancel");
1529 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1534 ctx.token.cancel();
1535 let outcome = ReadFileTool
1536 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1537 .await;
1538 assert!(outcome.was_cancelled());
1539 let _ = fs::remove_dir_all(&dir);
1540 }
1541
1542 #[tokio::test]
1543 async fn write_file_creates_and_counts_lines() {
1544 let dir = temp_root("write_ok");
1545 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1546 let outcome = WriteFileTool
1547 .execute(
1548 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1549 ctx,
1550 )
1551 .await;
1552 assert!(outcome.is_success(), "expected success: {outcome:?}");
1553 assert!(outcome.output().contains("3 lines"));
1554 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1555 assert!(written.contains("line1"));
1556 let _ = fs::remove_dir_all(&dir);
1557 }
1558
1559 #[tokio::test]
1560 async fn concurrent_write_file_same_path_serializes_cleanly() {
1561 let dir = temp_root("write_race");
1565 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1566 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1567 let a = "AAAA\nAAAA\n";
1568 let b = "BBBB\nBBBB\n";
1569 let (o1, o2) = tokio::join!(
1570 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1571 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1572 );
1573 assert!(o1.is_success(), "first write failed: {o1:?}");
1574 assert!(o2.is_success(), "second write failed: {o2:?}");
1575 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1576 assert!(
1577 final_content == a || final_content == b,
1578 "file must be exactly one clean write, got {final_content:?}"
1579 );
1580 let _ = fs::remove_dir_all(&dir);
1581 }
1582
1583 #[tokio::test]
1584 async fn write_file_new_file_records_added_display_diff() {
1585 let dir = temp_root("write_new_diff");
1586 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1587 let outcome = WriteFileTool
1588 .execute(
1589 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1590 ctx,
1591 )
1592 .await;
1593 assert!(outcome.is_success(), "expected success: {outcome:?}");
1594 let diff = outcome
1595 .metadata
1596 .display_diff
1597 .as_deref()
1598 .expect("display diff");
1599 assert!(diff.contains("+ alpha"));
1600 assert!(diff.contains("+ beta"));
1601 assert!(
1603 !diff.contains("@@"),
1604 "diff should not carry hunk headers: {diff}"
1605 );
1606 assert!(!diff.contains("/dev/null"));
1607 let _ = fs::remove_dir_all(&dir);
1608 }
1609
1610 #[tokio::test]
1611 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1612 let dir = temp_root("write_existing_diff");
1613 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1614 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1615 let outcome = WriteFileTool
1616 .execute(
1617 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1618 ctx,
1619 )
1620 .await;
1621 assert!(outcome.is_success(), "expected success: {outcome:?}");
1622 let diff = outcome
1623 .metadata
1624 .display_diff
1625 .as_deref()
1626 .expect("display diff");
1627 assert!(diff.contains("- old"));
1628 assert!(diff.contains("+ new"));
1629 let _ = fs::remove_dir_all(&dir);
1630 }
1631
1632 #[tokio::test]
1633 async fn write_file_creates_parent_dirs() {
1634 let dir = temp_root("write_parents");
1635 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1636 let outcome = WriteFileTool
1637 .execute(
1638 serde_json::json!({
1639 "path": "sub/nested/out.txt",
1640 "content": "deep",
1641 }),
1642 ctx,
1643 )
1644 .await;
1645 assert!(outcome.is_success(), "expected success: {outcome:?}");
1646 assert!(dir.join("sub/nested/out.txt").exists());
1647 let _ = fs::remove_dir_all(&dir);
1648 }
1649
1650 #[tokio::test]
1651 async fn write_file_missing_content_errors() {
1652 let dir = temp_root("write_missing");
1653 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1654 let outcome = WriteFileTool
1655 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1656 .await;
1657 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1658 let _ = fs::remove_dir_all(&dir);
1659 }
1660
1661 #[tokio::test]
1668 async fn read_file_allows_absolute_path_outside_workdir() {
1669 let dir = temp_root("read_abs_escape");
1670 let external_dir = temp_root("read_abs_external");
1671 let external_file = external_dir.join("ext.txt");
1672 fs::write(&external_file, "external content").unwrap();
1673 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1674 let outcome = ReadFileTool
1675 .execute(
1676 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
1677 ctx,
1678 )
1679 .await;
1680 assert!(outcome.is_success(), "expected success: {outcome:?}");
1681 assert_eq!(outcome.output(), "external content");
1682 let _ = fs::remove_dir_all(&dir);
1683 let _ = fs::remove_dir_all(&external_dir);
1684 }
1685
1686 #[tokio::test]
1688 async fn read_file_accepts_absolute_path_inside_workdir() {
1689 let dir = temp_root("read_abs_inside");
1690 let file = dir.join("hello.txt");
1691 fs::write(&file, "ok").expect("write fixture");
1692 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1693 let outcome = ReadFileTool
1694 .execute(
1695 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1696 ctx,
1697 )
1698 .await;
1699 assert!(outcome.is_success(), "expected success: {outcome:?}");
1700 let _ = fs::remove_dir_all(&dir);
1701 }
1702
1703 #[tokio::test]
1705 async fn write_file_allows_relative_parent_and_external_path() {
1706 let base = temp_root("write_dotdot_escape");
1707 let dir = base.join("project");
1708 fs::create_dir_all(&dir).unwrap();
1709 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1710 let outcome = WriteFileTool
1711 .execute(
1712 serde_json::json!({
1713 "path": "../escape.txt",
1714 "content": "written outside",
1715 }),
1716 ctx,
1717 )
1718 .await;
1719 assert!(
1720 outcome.is_success(),
1721 "expected success for write to ../escape.txt"
1722 );
1723 let written = base.join("escape.txt");
1724 assert!(written.exists());
1725 assert_eq!(fs::read_to_string(&written).unwrap(), "written outside");
1726 let _ = fs::remove_dir_all(&base);
1727 }
1728
1729 #[tokio::test]
1734 async fn create_directory_allows_absolute_path_outside_workdir() {
1735 let dir = temp_root("mkdir_abs_proj");
1736 let target = std::env::temp_dir().join(format!("mermaid_fs_target_{}", std::process::id()));
1737 let _ = fs::remove_dir_all(&target);
1738 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1739 let outcome = CreateDirectoryTool
1740 .execute(
1741 serde_json::json!({"path": target.to_string_lossy().to_string()}),
1742 ctx,
1743 )
1744 .await;
1745 assert!(outcome.is_success(), "expected success: {outcome:?}");
1746 assert!(target.exists());
1747 let _ = fs::remove_dir_all(&target);
1748 let _ = fs::remove_dir_all(&dir);
1749 }
1750
1751 fn scratch_ctx(
1757 mode: mermaid_runtime::SafetyMode,
1758 workdir: PathBuf,
1759 scratchpad: Option<PathBuf>,
1760 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1761 let mut config = mermaid_domain::Config::default();
1762 config.safety.mode = mode;
1763 let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
1764 TurnId(1),
1765 ToolCallId(1),
1766 workdir,
1767 config,
1768 );
1769 ctx.scratchpad = scratchpad;
1770 (ctx, rx)
1771 }
1772
1773 fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1775 let base = std::env::temp_dir().join(format!(
1776 "mermaid_fs_scratch_{}_{}",
1777 name,
1778 std::process::id()
1779 ));
1780 let _ = fs::remove_dir_all(&base);
1781 let project = base.join("project");
1782 let scratch = base.join("scratch");
1783 fs::create_dir_all(&project).unwrap();
1784 fs::create_dir_all(&scratch).unwrap();
1785 (project, scratch)
1786 }
1787
1788 fn any_checkpoint_mentions(marker: &str) -> bool {
1792 let Ok(data) = mermaid_runtime::data_dir() else {
1793 return false;
1794 };
1795 let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1796 return false;
1797 };
1798 entries.flatten().any(|entry| {
1799 fs::read_to_string(entry.path().join("manifest.json"))
1800 .is_ok_and(|manifest| manifest.contains(marker))
1801 })
1802 }
1803
1804 #[tokio::test]
1807 async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1808 let (project, scratch) = scratch_fixture("ungated");
1809 let marker = scratch.display().to_string();
1810
1811 let file = scratch.join("notes.txt");
1813 let (ctx, _rx) = scratch_ctx(
1814 mermaid_runtime::SafetyMode::Ask,
1815 project.clone(),
1816 Some(scratch.clone()),
1817 );
1818 let outcome = WriteFileTool
1819 .execute(
1820 serde_json::json!({
1821 "path": file.to_str().unwrap(),
1822 "content": "scratch note\n",
1823 }),
1824 ctx,
1825 )
1826 .await;
1827 assert!(outcome.is_success(), "scratch write: {outcome:?}");
1828 assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1829
1830 let subdir = scratch.join("work/area");
1832 let (ctx, _rx) = scratch_ctx(
1833 mermaid_runtime::SafetyMode::Ask,
1834 project.clone(),
1835 Some(scratch.clone()),
1836 );
1837 let outcome = CreateDirectoryTool
1838 .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1839 .await;
1840 assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1841 assert!(subdir.is_dir());
1842
1843 let (ctx, _rx) = scratch_ctx(
1845 mermaid_runtime::SafetyMode::Ask,
1846 project.clone(),
1847 Some(scratch.clone()),
1848 );
1849 let outcome = DeleteFileTool
1850 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1851 .await;
1852 assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1853 assert!(!file.exists());
1854
1855 assert!(
1857 !any_checkpoint_mentions(&marker),
1858 "scratch mutation must not create a checkpoint"
1859 );
1860 let _ = fs::remove_dir_all(project.parent().unwrap());
1861 }
1862
1863 #[tokio::test]
1866 async fn scratch_mutation_blocked_in_read_only() {
1867 let (project, scratch) = scratch_fixture("readonly");
1868 let file = scratch.join("blocked.txt");
1869 let (ctx, _rx) = scratch_ctx(
1870 mermaid_runtime::SafetyMode::ReadOnly,
1871 project.clone(),
1872 Some(scratch.clone()),
1873 );
1874 let outcome = WriteFileTool
1875 .execute(
1876 serde_json::json!({
1877 "path": file.to_str().unwrap(),
1878 "content": "nope",
1879 }),
1880 ctx,
1881 )
1882 .await;
1883 let error = outcome.error_message().expect("expected block");
1884 assert!(
1885 error.contains("blocked by policy"),
1886 "expected policy block, got: {error}"
1887 );
1888 assert!(!file.exists());
1889 let _ = fs::remove_dir_all(project.parent().unwrap());
1890 }
1891
1892 #[tokio::test]
1894 async fn write_outside_both_roots_requires_approval_in_ask_mode() {
1895 let (project, scratch) = scratch_fixture("outside");
1896 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
1897 let (ctx, _rx) = scratch_ctx(
1898 mermaid_runtime::SafetyMode::Ask,
1899 project.clone(),
1900 Some(scratch.clone()),
1901 );
1902 let outcome = WriteFileTool
1903 .execute(
1904 serde_json::json!({
1905 "path": outside.to_str().unwrap(),
1906 "content": "should require approval",
1907 }),
1908 ctx,
1909 )
1910 .await;
1911 let error = outcome.error_message().expect("expected approval required");
1912 assert!(
1913 error.contains("Approval required"),
1914 "expected approval block, got: {error}"
1915 );
1916 assert!(!outside.exists());
1917 let _ = fs::remove_dir_all(project.parent().unwrap());
1918 }
1919
1920 #[tokio::test]
1922 async fn read_file_reads_from_scratchpad() {
1923 let (project, scratch) = scratch_fixture("read");
1924 let file = scratch.join("stash.txt");
1925 fs::write(&file, "stashed").unwrap();
1926 let (ctx, _rx) = scratch_ctx(
1927 mermaid_runtime::SafetyMode::Ask,
1928 project.clone(),
1929 Some(scratch.clone()),
1930 );
1931 let outcome = ReadFileTool
1932 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1933 .await;
1934 assert!(outcome.is_success(), "scratch read: {outcome:?}");
1935 assert_eq!(outcome.output(), "stashed");
1936 let _ = fs::remove_dir_all(project.parent().unwrap());
1937 }
1938
1939 #[tokio::test]
1940 async fn edit_file_replaces_target_content_successfully() {
1941 let (project, _scratch) = scratch_fixture("edit_success");
1942 let file = project.join("src").join("main.rs");
1943 fs::create_dir_all(file.parent().unwrap()).unwrap();
1944 fs::write(&file, "fn main() {\n println!(\"old\");\n}\n").unwrap();
1945
1946 let (ctx, _rx) = scratch_ctx(
1947 mermaid_runtime::SafetyMode::FullAccess,
1948 project.clone(),
1949 None,
1950 );
1951 let outcome = EditFileTool
1952 .execute(
1953 serde_json::json!({
1954 "path": "src/main.rs",
1955 "target_content": " println!(\"old\");",
1956 "replacement_content": " println!(\"new\");",
1957 }),
1958 ctx,
1959 )
1960 .await;
1961
1962 assert!(outcome.is_success(), "edit outcome: {outcome:?}");
1963 let new_content = fs::read_to_string(&file).unwrap();
1964 assert_eq!(new_content, "fn main() {\n println!(\"new\");\n}\n");
1965 let diff = outcome
1966 .metadata
1967 .display_diff
1968 .as_deref()
1969 .expect("display diff");
1970 assert!(diff.contains("- println!(\"old\");"));
1971 assert!(diff.contains("+ println!(\"new\");"));
1972 let _ = fs::remove_dir_all(project.parent().unwrap());
1973 }
1974
1975 #[tokio::test]
1976 async fn edit_file_target_not_found_returns_error() {
1977 let (project, _scratch) = scratch_fixture("edit_not_found");
1978 let file = project.join("hello.txt");
1979 fs::write(&file, "line 1\nline 2\n").unwrap();
1980
1981 let (ctx, _rx) = scratch_ctx(
1982 mermaid_runtime::SafetyMode::FullAccess,
1983 project.clone(),
1984 None,
1985 );
1986 let outcome = EditFileTool
1987 .execute(
1988 serde_json::json!({
1989 "path": "hello.txt",
1990 "target_content": "nonexistent text",
1991 "replacement_content": "replacement",
1992 }),
1993 ctx,
1994 )
1995 .await;
1996
1997 assert!(!outcome.is_success());
1998 let err = outcome.error_message().unwrap();
1999 assert!(err.contains("could not find target_content"), "{err}");
2000 let _ = fs::remove_dir_all(project.parent().unwrap());
2001 }
2002
2003 #[tokio::test]
2004 async fn edit_file_ambiguous_target_errors_unless_allow_multiple() {
2005 let (project, _scratch) = scratch_fixture("edit_ambig");
2006 let file = project.join("dup.txt");
2007 fs::write(&file, "foo\nbar\nfoo\n").unwrap();
2008
2009 let (ctx, _rx) = scratch_ctx(
2010 mermaid_runtime::SafetyMode::FullAccess,
2011 project.clone(),
2012 None,
2013 );
2014 let outcome_ambig = EditFileTool
2015 .execute(
2016 serde_json::json!({
2017 "path": "dup.txt",
2018 "target_content": "foo",
2019 "replacement_content": "baz",
2020 }),
2021 ctx,
2022 )
2023 .await;
2024 assert!(!outcome_ambig.is_success());
2025 let err = outcome_ambig.error_message().unwrap();
2026 assert!(err.contains("found 2 times"), "{err}");
2027
2028 let (ctx2, _rx2) = scratch_ctx(
2029 mermaid_runtime::SafetyMode::FullAccess,
2030 project.clone(),
2031 None,
2032 );
2033 let outcome_allow = EditFileTool
2034 .execute(
2035 serde_json::json!({
2036 "path": "dup.txt",
2037 "target_content": "foo",
2038 "replacement_content": "baz",
2039 "allow_multiple": true,
2040 }),
2041 ctx2,
2042 )
2043 .await;
2044 assert!(outcome_allow.is_success());
2045 let new_content = fs::read_to_string(&file).unwrap();
2046 assert_eq!(new_content, "baz\nbar\nbaz\n");
2047 let _ = fs::remove_dir_all(project.parent().unwrap());
2048 }
2049
2050 #[tokio::test]
2051 async fn edit_file_fuzzy_whitespace_matching_succeeds() {
2052 let (project, _scratch) = scratch_fixture("edit_fuzzy");
2053 let file = project.join("fuzzy.txt");
2054 fs::write(&file, "start\n loose_whitespace(); \nend\n").unwrap();
2055
2056 let (ctx, _rx) = scratch_ctx(
2057 mermaid_runtime::SafetyMode::FullAccess,
2058 project.clone(),
2059 None,
2060 );
2061 let outcome = EditFileTool
2062 .execute(
2063 serde_json::json!({
2064 "path": "fuzzy.txt",
2065 "target_content": "loose_whitespace();",
2066 "replacement_content": " tight_whitespace();",
2067 }),
2068 ctx,
2069 )
2070 .await;
2071 assert!(outcome.is_success());
2072 assert!(outcome.output().contains("fuzzy"));
2073 let new_content = fs::read_to_string(&file).unwrap();
2074 assert_eq!(new_content, "start\n tight_whitespace();\nend\n");
2075 let _ = fs::remove_dir_all(project.parent().unwrap());
2076 }
2077
2078 fn external_read_fixture(name: &str) -> (PathBuf, PathBuf) {
2082 let workdir = temp_root(&format!("{name}_project"));
2083 let external_dir = temp_root(&format!("{name}_external"));
2084 let external_file = external_dir.join("ext.txt");
2085 fs::write(&external_file, "external content").unwrap();
2086 (workdir, external_file)
2087 }
2088
2089 fn ctx_in_mode(
2090 mode: mermaid_runtime::SafetyMode,
2091 workdir: PathBuf,
2092 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2093 let mut config = mermaid_domain::Config::default();
2094 config.safety.mode = mode;
2095 crate::providers::ctx::test_exec_context_with_config(
2096 TurnId(1),
2097 ToolCallId(1),
2098 workdir,
2099 config,
2100 )
2101 }
2102
2103 async fn read_external(ctx: ExecContext, external_file: &Path) -> ToolOutcome {
2104 ReadFileTool
2105 .execute(
2106 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
2107 ctx,
2108 )
2109 .await
2110 }
2111
2112 #[tokio::test]
2116 async fn read_outside_the_project_is_denied_under_the_read_only_floor() {
2117 let (workdir, external_file) = external_read_fixture("read_ext_readonly");
2118 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2119 let outcome = read_external(ctx, &external_file).await;
2120 assert_eq!(
2121 outcome.status,
2122 mermaid_domain::ToolStatus::Error,
2123 "read_only must deny an external read: {outcome:?}"
2124 );
2125 assert!(
2126 !outcome.output().contains("external content"),
2127 "read_only leaked the file body: {}",
2128 outcome.output()
2129 );
2130 let _ = fs::remove_dir_all(&workdir);
2131 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2132 }
2133
2134 #[tokio::test]
2137 async fn read_inside_the_project_stays_ungated_in_read_only_mode() {
2138 let workdir = temp_root("read_inside_readonly");
2139 fs::write(workdir.join("notes.md"), "project content").unwrap();
2140 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2141 let outcome = ReadFileTool
2142 .execute(serde_json::json!({"path": "notes.md"}), ctx)
2143 .await;
2144 assert_eq!(
2145 outcome.status,
2146 mermaid_domain::ToolStatus::Success,
2147 "{outcome:?}"
2148 );
2149 assert_eq!(outcome.output(), "project content");
2150 let _ = fs::remove_dir_all(&workdir);
2151 }
2152
2153 #[tokio::test]
2157 async fn read_outside_the_project_prompts_in_ask_mode_scoped_to_its_directory() {
2158 let (workdir, external_file) = external_read_fixture("read_ext_ask");
2159 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2160 let broker = crate::providers::ApprovalBroker::new(tx);
2161 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2162 ctx.approval = Some(broker.clone());
2163 let file_for_task = external_file.clone();
2164 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2165
2166 let (call_id, tool, scope) = match rx.recv().await.expect("approval requested") {
2167 mermaid_domain::Msg::ApprovalRequested {
2168 call_id,
2169 tool,
2170 allowlist_scope,
2171 ..
2172 } => (call_id, tool, allowlist_scope),
2173 other => panic!("expected ApprovalRequested, got {other:?}"),
2174 };
2175 assert_eq!(tool, "read_file");
2176 let expected_dir = external_file.canonicalize().unwrap();
2177 let expected_dir = expected_dir.parent().unwrap();
2178 assert_eq!(
2179 scope,
2180 format!("read_file:{}", expected_dir.display()),
2181 "external reads are allowlisted per directory"
2182 );
2183
2184 broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
2185 let outcome = handle.await.unwrap();
2186 assert_eq!(
2187 outcome.status,
2188 mermaid_domain::ToolStatus::Success,
2189 "{outcome:?}"
2190 );
2191 assert_eq!(outcome.output(), "external content");
2192 let _ = fs::remove_dir_all(&workdir);
2193 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2194 }
2195
2196 #[tokio::test]
2198 async fn read_outside_the_project_denied_by_the_user_is_an_error() {
2199 let (workdir, external_file) = external_read_fixture("read_ext_deny");
2200 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2201 let broker = crate::providers::ApprovalBroker::new(tx);
2202 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2203 ctx.approval = Some(broker.clone());
2204 let file_for_task = external_file.clone();
2205 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2206 let call_id = match rx.recv().await.expect("approval requested") {
2207 mermaid_domain::Msg::ApprovalRequested { call_id, .. } => call_id,
2208 other => panic!("expected ApprovalRequested, got {other:?}"),
2209 };
2210 broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
2211 let outcome = handle.await.unwrap();
2212 assert_eq!(
2213 outcome.status,
2214 mermaid_domain::ToolStatus::Error,
2215 "{outcome:?}"
2216 );
2217 assert!(!outcome.output().contains("external content"));
2218 let _ = fs::remove_dir_all(&workdir);
2219 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2220 }
2221
2222 #[cfg(unix)]
2226 #[tokio::test]
2227 async fn a_symlink_inside_the_project_that_points_outside_is_gated() {
2228 let (workdir, external_file) = external_read_fixture("read_ext_symlink");
2229 std::os::unix::fs::symlink(&external_file, workdir.join("innocent.txt")).unwrap();
2230 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2231 let outcome = ReadFileTool
2232 .execute(serde_json::json!({"path": "innocent.txt"}), ctx)
2233 .await;
2234 assert_eq!(
2235 outcome.status,
2236 mermaid_domain::ToolStatus::Error,
2237 "{outcome:?}"
2238 );
2239 assert!(!outcome.output().contains("external content"));
2240 let _ = fs::remove_dir_all(&workdir);
2241 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2242 }
2243
2244 #[tokio::test]
2249 async fn write_outside_the_project_is_not_silently_allowed_in_auto_mode() {
2250 let (project, scratch) = scratch_fixture("outside_auto");
2251 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2252 let (ctx, _rx) = scratch_ctx(
2253 mermaid_runtime::SafetyMode::Auto,
2254 project.clone(),
2255 Some(scratch.clone()),
2256 );
2257 let outcome = WriteFileTool
2258 .execute(
2259 serde_json::json!({
2260 "path": outside.to_str().unwrap(),
2261 "content": "must not land unasked",
2262 }),
2263 ctx,
2264 )
2265 .await;
2266 assert_eq!(
2267 outcome.status,
2268 mermaid_domain::ToolStatus::Error,
2269 "auto mode must escalate an external write: {outcome:?}"
2270 );
2271 assert!(!outside.exists(), "the external file was written anyway");
2272 let _ = fs::remove_dir_all(project.parent().unwrap());
2273 }
2274
2275 #[tokio::test]
2278 async fn write_inside_the_project_stays_allowed_in_auto_mode() {
2279 let (project, scratch) = scratch_fixture("inside_auto");
2280 let (ctx, _rx) = scratch_ctx(
2281 mermaid_runtime::SafetyMode::Auto,
2282 project.clone(),
2283 Some(scratch.clone()),
2284 );
2285 let outcome = WriteFileTool
2286 .execute(
2287 serde_json::json!({"path": "inside.txt", "content": "fine"}),
2288 ctx,
2289 )
2290 .await;
2291 assert_eq!(
2292 outcome.status,
2293 mermaid_domain::ToolStatus::Success,
2294 "{outcome:?}"
2295 );
2296 assert_eq!(
2297 fs::read_to_string(project.join("inside.txt")).unwrap(),
2298 "fine"
2299 );
2300 let _ = fs::remove_dir_all(project.parent().unwrap());
2301 }
2302}