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, 0.0),
168 };
169 if paths.is_empty() {
170 return ToolOutcome::error("read_file requires at least one path", 0.0);
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 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 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 #[expect(
523 clippy::too_many_lines,
524 reason = "the gated write path in order: parse, resolve in roots, policy gate, per-path \
525 lock, checkpoint, then the blocking write with its display diff; each step is a guard \
526 returning its own outcome, and the closing select needs everything the earlier steps \
527 produced (path, counts, plan flag, timer)"
528 )]
529 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
530 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
531 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
532 };
533 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
534 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
535 };
536
537 let start = std::time::Instant::now();
538 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
539 let ResolvedInRoot {
542 abs: abs_path,
543 rel,
544 root,
545 containment,
546 } = match resolve_in_roots(&roots, path) {
547 Ok(r) => r,
548 Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
549 };
550 let pending_action = serde_json::json!({
551 "tool": "write_file",
552 "args": { "path": path, "content": content },
553 "workdir": ctx.workdir.display().to_string(),
554 "turn_id": ctx.turn.0,
555 "call_id": ctx.call_id.0,
556 "task_id": ctx.task_id.clone(),
557 });
558 let plan_write = match mutation_policy_outcome(
559 &ctx,
560 "write_file",
561 path,
562 std::slice::from_ref(&abs_path),
563 pending_action,
564 containment,
565 )
566 .await
567 {
568 MutationGate::Blocked(outcome) => return *outcome,
569 MutationGate::Proceed { plan_write } => plan_write,
570 };
571 let _write_guard = tokio::select! {
576 biased;
577 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
578 g = super::path_lock::lock_path(&abs_path) => g,
579 };
580 if ctx.config.safety.checkpoint_on_mutation
582 && containment != PathContainment::Scratchpad
583 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
584 &ctx.workdir,
585 std::slice::from_ref(&abs_path),
586 Some(serde_json::json!({
587 "tool": "write_file",
588 "path": path,
589 })),
590 ctx.checkpoint_origin(),
591 )
592 {
593 return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
594 }
595 let display_path = path.to_string();
596 let line_count = content.lines().count();
597 let byte_count = content.len();
598 let content = content.to_string();
599
600 tokio::select! {
601 biased;
602 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
603 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
607 match result {
608 Ok(Ok(write)) => {
609 let duration_secs = start.elapsed().as_secs_f64();
610 after_file_mutation(&ctx, "write_file", &display_path);
611 ToolOutcome::success(
612 format!("Wrote {} ({} lines)", display_path, write.line_count),
613 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
614 duration_secs,
615 )
616 .with_metadata(ToolRunMetadata {
617 detail: ToolMetadata::WriteFile {
618 path: display_path,
619 line_count,
620 byte_count,
621 created: Some(write.created),
622 },
623 line_count: Some(line_count),
624 byte_count: Some(byte_count),
625 display_diff: Some(write.diff.display_diff),
626 diff_truncated: write.diff.truncated,
627 lines_added: write.diff.added,
628 lines_removed: write.diff.removed,
629 plan_file_written: plan_write,
630 ..ToolRunMetadata::default()
631 })
632 },
633 Ok(Err(e)) => ToolOutcome::error(
634 format!("write_file({display_path}): {e}"),
635 start.elapsed().as_secs_f64(),
636 ),
637 Err(e) => ToolOutcome::error(
638 format!("write_file join error: {e}"),
639 start.elapsed().as_secs_f64(),
640 ),
641 }
642 }
643 }
644 }
645}
646
647pub struct EditFileTool;
649
650#[async_trait]
651impl ToolExecutor for EditFileTool {
652 fn name(&self) -> &'static str {
653 "edit_file"
654 }
655
656 fn schema(&self) -> ToolDefinition {
657 defn(
658 "edit_file",
659 "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.",
660 serde_json::json!({
661 "type": "object",
662 "properties": {
663 "path": {
664 "type": "string",
665 "description": "Path to the file to edit."
666 },
667 "target_content": {
668 "type": "string",
669 "description": "The exact or near-exact block of text to replace. Must match uniquely in the file unless allow_multiple is true."
670 },
671 "replacement_content": {
672 "type": "string",
673 "description": "The new replacement text."
674 },
675 "allow_multiple": {
676 "type": "boolean",
677 "description": "If true, replace all occurrences of target_content instead of requiring uniqueness. Defaults to false."
678 }
679 },
680 "required": ["path", "target_content", "replacement_content"]
681 }),
682 )
683 }
684
685 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
686 let start = std::time::Instant::now();
687 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
688 return ToolOutcome::error("edit_file requires 'path' (string)", 0.0);
689 };
690 let Some(target) = args.get("target_content").and_then(|v| v.as_str()) else {
691 return ToolOutcome::error("edit_file requires 'target_content' (string)", 0.0);
692 };
693 let Some(replacement) = args.get("replacement_content").and_then(|v| v.as_str()) else {
694 return ToolOutcome::error("edit_file requires 'replacement_content' (string)", 0.0);
695 };
696 let allow_multiple = args
697 .get("allow_multiple")
698 .and_then(serde_json::Value::as_bool)
699 .unwrap_or(false);
700
701 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
702 let ResolvedInRoot {
703 abs: abs_path,
704 rel,
705 root,
706 containment,
707 } = match resolve_in_roots(&roots, path) {
708 Ok(r) => r,
709 Err(e) => return ToolOutcome::error(format!("edit_file: {e}"), 0.0),
710 };
711
712 let pending_action = serde_json::json!({
713 "tool": "edit_file",
714 "args": {
715 "path": path,
716 "target_content": target,
717 "replacement_content": replacement,
718 "allow_multiple": allow_multiple,
719 },
720 "workdir": ctx.workdir.display().to_string(),
721 "turn_id": ctx.turn.0,
722 "call_id": ctx.call_id.0,
723 "task_id": ctx.task_id.clone(),
724 });
725 let plan_write = match mutation_policy_outcome(
726 &ctx,
727 "edit_file",
728 path,
729 std::slice::from_ref(&abs_path),
730 pending_action,
731 containment,
732 )
733 .await
734 {
735 MutationGate::Blocked(outcome) => return *outcome,
736 MutationGate::Proceed { plan_write } => plan_write,
737 };
738
739 let _write_guard = tokio::select! {
740 biased;
741 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
742 g = super::path_lock::lock_path(&abs_path) => g,
743 };
744
745 if ctx.config.safety.checkpoint_on_mutation
746 && containment != PathContainment::Scratchpad
747 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
748 &ctx.workdir,
749 std::slice::from_ref(&abs_path),
750 Some(serde_json::json!({
751 "tool": "edit_file",
752 "path": path,
753 })),
754 ctx.checkpoint_origin(),
755 )
756 {
757 return ToolOutcome::error(format!("edit_file checkpoint failed: {e}"), 0.0);
758 }
759
760 let display_path = path.to_string();
761 let target = target.to_string();
762 let replacement = replacement.to_string();
763
764 tokio::select! {
765 biased;
766 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
767 result = tokio::task::spawn_blocking(move || edit_file_blocking(&root, &rel, &target, &replacement, allow_multiple)) => {
768 match result {
769 Ok(Ok(edit)) => {
770 let duration_secs = start.elapsed().as_secs_f64();
771 after_file_mutation(&ctx, "edit_file", &display_path);
772 edit_success_outcome(&display_path, edit, plan_write, duration_secs)
773 },
774 Ok(Err(e)) => ToolOutcome::error(
775 format!("edit_file({display_path}): {e}"),
776 start.elapsed().as_secs_f64(),
777 ),
778 Err(e) => ToolOutcome::error(
779 format!("edit_file join error: {e}"),
780 start.elapsed().as_secs_f64(),
781 ),
782 }
783 }
784 }
785 }
786}
787
788fn edit_success_outcome(
789 display_path: &str,
790 edit: EditResult,
791 plan_write: bool,
792 duration_secs: f64,
793) -> ToolOutcome {
794 let fuzzy_note = if edit.fuzzy {
795 "\nnote: matched with fuzzy (whitespace/Unicode) context; verify the result."
796 } else {
797 ""
798 };
799 ToolOutcome::success(
800 format!("Edited {display_path}{fuzzy_note}"),
801 diff_summary(edit.diff.added, edit.diff.removed, duration_secs),
802 duration_secs,
803 )
804 .with_metadata(ToolRunMetadata {
805 detail: ToolMetadata::ApplyPatch {
806 added: Vec::new(),
807 modified: vec![display_path.to_string()],
808 deleted: Vec::new(),
809 renamed: Vec::new(),
810 fuzzy: edit.fuzzy,
811 },
812 display_diff: Some(edit.diff.display_diff),
813 diff_truncated: edit.diff.truncated,
814 lines_added: edit.diff.added,
815 lines_removed: edit.diff.removed,
816 plan_file_written: plan_write,
817 ..ToolRunMetadata::default()
818 })
819}
820
821fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
824 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
826 reject_web_url(p)?;
827 return Ok(vec![p.to_string()]);
828 }
829 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
830 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
831 return Err(format!(
832 "read_file: too many paths ({}); cap is {} per call — split the request",
833 arr.len(),
834 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
835 ));
836 }
837 let mut out = Vec::with_capacity(arr.len());
838 for v in arr {
839 let Some(s) = v.as_str() else {
840 return Err("read_file 'paths' must be an array of strings".to_string());
841 };
842 reject_web_url(s)?;
843 out.push(s.to_string());
844 }
845 return Ok(out);
846 }
847 Err("read_file requires 'path' or 'paths'".to_string())
848}
849
850fn reject_web_url(path: &str) -> Result<(), String> {
856 let head: String = path
857 .trim_start()
858 .chars()
859 .take(8)
860 .collect::<String>()
861 .to_ascii_lowercase();
862 if head.starts_with("http://") || head.starts_with("https://") {
863 return Err(format!(
864 "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
865 ));
866 }
867 Ok(())
868}
869
870fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
878 if !Path::new(raw).is_absolute() {
879 return None;
880 }
881 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
882 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
883 && let Ok(rel) = relative_within(&root, raw)
884 {
885 return Some((root, rel));
886 }
887 }
888 None
889}
890
891struct ReadTarget {
894 root: PathBuf,
895 rel: PathBuf,
896 external: Option<PathBuf>,
899}
900
901fn resolve_read_target(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<ReadTarget> {
910 match resolve_in_roots(roots, raw) {
911 Ok(ResolvedInRoot {
912 abs,
913 rel,
914 root,
915 containment,
916 }) => match containment {
917 PathContainment::Project | PathContainment::Scratchpad => Ok(ReadTarget {
918 root,
919 rel,
920 external: None,
921 }),
922 PathContainment::External => Ok(resolve_in_memory_roots(roots.workdir, raw).map_or(
923 ReadTarget {
924 root,
925 rel,
926 external: Some(abs),
927 },
928 |(root, rel)| ReadTarget {
929 root,
930 rel,
931 external: None,
932 },
933 )),
934 },
935 Err(msg) => {
936 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
937 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
938 Ok(ReadTarget {
939 root,
940 rel,
941 external: None,
942 })
943 },
944 }
945}
946
947async fn external_read_gate(ctx: &ExecContext, raw: &str, abs: &Path) -> Option<ToolOutcome> {
958 let mut request = mermaid_runtime::ActionRequest::new(
959 "read_file",
960 mermaid_runtime::ToolCategory::ExternalDirectory,
961 format!("read_file {raw}"),
962 );
963 request.path = Some(abs.display().to_string());
964 let pending_action = serde_json::json!({
965 "tool": "read_file",
966 "args": { "path": raw },
967 "workdir": ctx.workdir.display().to_string(),
968 "turn_id": ctx.turn.0,
969 "call_id": ctx.call_id.0,
970 "task_id": ctx.task_id.clone(),
971 });
972 match super::policy_gate::gate(ctx, request, &[], pending_action, false, false).await {
973 super::policy_gate::Gate::Block(outcome) => Some(outcome),
974 super::policy_gate::Gate::Proceed { .. } => None,
975 }
976}
977
978async fn read_one(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool)> {
988 let result = tokio::task::spawn_blocking(move || {
989 let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
990 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
994 let mut s = String::from_utf8_lossy(&data).into_owned();
995 if truncated {
996 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
998 s.truncate(cut);
999 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
1000 }
1001 Ok::<_, std::io::Error>((s, truncated))
1002 })
1003 .await
1004 .map_err(|e| std::io::Error::other(e.to_string()))??;
1005 Ok(result)
1006}
1007
1008fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
1017 if let Some(parent) = rel.parent()
1018 && !parent.as_os_str().is_empty()
1019 {
1020 mermaid_runtime::create_dir_all_beneath(root, parent)?;
1021 }
1022 mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
1023 Ok(content.lines().count())
1024}
1025
1026struct WriteResult {
1027 line_count: usize,
1028 created: bool,
1029 diff: mermaid_model::diff::DisplayDiff,
1030}
1031
1032fn write_with_diff_blocking(
1039 root: &Path,
1040 abs_path: &Path,
1041 rel: &Path,
1042 content: &str,
1043) -> std::io::Result<WriteResult> {
1044 let (old_content, created, elide_diff) =
1045 match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
1046 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
1047 Ok((_, true)) => (String::new(), false, true),
1049 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1051 Err(_) => (String::new(), false, true),
1053 };
1054 let diff = if elide_diff {
1055 mermaid_model::diff::DisplayDiff {
1056 display_diff: format!(
1057 "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
1058 ),
1059 added: 0,
1060 removed: 0,
1061 truncated: true,
1062 }
1063 } else {
1064 mermaid_model::diff::generate_display_diff(&old_content, content)
1065 };
1066 let line_count = write_one_blocking(root, rel, content)?;
1067 Ok(WriteResult {
1068 line_count,
1069 created,
1070 diff,
1071 })
1072}
1073
1074struct EditResult {
1075 diff: mermaid_model::diff::DisplayDiff,
1076 fuzzy: bool,
1077}
1078
1079fn edit_file_blocking(
1080 root: &Path,
1081 rel: &Path,
1082 target: &str,
1083 replacement: &str,
1084 allow_multiple: bool,
1085) -> Result<EditResult, String> {
1086 let file = mermaid_runtime::open_beneath(root, rel, mermaid_runtime::OpenIntent::Read)
1087 .map_err(|e| format!("cannot open file: {e}"))?;
1088 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)
1089 .map_err(|e| format!("cannot read file: {e}"))?;
1090 if truncated {
1091 return Err(format!(
1092 "file exceeds maximum size limit ({MAX_FILE_READ_BYTES} bytes)"
1093 ));
1094 }
1095 let original = String::from_utf8_lossy(&data).into_owned();
1096 let applied = mermaid_runtime::replace_content(&original, target, replacement, allow_multiple)
1097 .map_err(|e| e.to_string())?;
1098
1099 write_one_blocking(root, rel, &applied.new_contents)
1100 .map_err(|e| format!("cannot write file: {e}"))?;
1101
1102 let diff = mermaid_model::diff::generate_display_diff(&original, &applied.new_contents);
1103 Ok(EditResult {
1104 diff,
1105 fuzzy: applied.fuzzy,
1106 })
1107}
1108
1109pub(super) enum MutationGate {
1117 Blocked(Box<ToolOutcome>),
1119 Proceed {
1120 plan_write: bool,
1121 },
1122}
1123
1124pub(super) async fn mutation_policy_outcome(
1139 ctx: &ExecContext,
1140 tool: &str,
1141 path: &str,
1142 checkpoint_paths: &[PathBuf],
1143 pending_action: serde_json::Value,
1144 containment: PathContainment,
1145) -> MutationGate {
1146 let category = match containment {
1147 PathContainment::Project | PathContainment::Scratchpad => {
1148 mermaid_runtime::ToolCategory::Edit
1149 },
1150 PathContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
1151 };
1152 let mut request = mermaid_runtime::ActionRequest::new(tool, category, format!("{tool} {path}"));
1153 request.path = Some(path.to_string());
1154 match super::policy_gate::gate(
1157 ctx,
1158 request,
1159 checkpoint_paths,
1160 pending_action,
1161 true,
1162 containment == PathContainment::Scratchpad,
1163 )
1164 .await
1165 {
1166 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
1167 super::policy_gate::Gate::Proceed { plan_write, .. } => {
1168 let _ = mermaid_runtime::run_plugin_hooks(
1169 "before_file_mutation",
1170 &serde_json::json!({
1171 "task_id": ctx.task_id.clone(),
1172 "turn_id": ctx.turn.0,
1173 "call_id": ctx.call_id.0,
1174 "tool": tool,
1175 "path": path,
1176 }),
1177 );
1178 MutationGate::Proceed { plan_write }
1179 },
1180 }
1181}
1182
1183pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
1184 let _ = mermaid_runtime::run_plugin_hooks(
1185 "after_file_mutation",
1186 &serde_json::json!({
1187 "task_id": ctx.task_id.clone(),
1188 "turn_id": ctx.turn.0,
1189 "call_id": ctx.call_id.0,
1190 "tool": tool,
1191 "path": path,
1192 }),
1193 );
1194}
1195
1196fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
1197 ToolOutcome::error(msg, duration_secs)
1198}
1199
1200fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
1201 if count == 1 { singular } else { plural }
1202}
1203
1204pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
1205 format!(
1206 "+{} -{}, took {}",
1207 added,
1208 removed,
1209 format_duration_for_diff(duration_secs)
1210 )
1211}
1212
1213fn format_duration_for_diff(seconds: f64) -> String {
1214 if seconds < 1.0 {
1215 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1216 } else if seconds < 10.0 {
1217 format!("{seconds:.1}s")
1218 } else {
1219 format!("{}s", seconds.round() as u64)
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use super::*;
1226 use crate::providers::ctx::test_exec_context;
1227 use mermaid_domain::{ToolCallId, TurnId};
1228 use std::fs;
1229
1230 #[tokio::test]
1236 async fn read_file_resolves_memory_roots_read_only() {
1237 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
1238 let _ = fs::remove_dir_all(&base);
1239 let repo = base.join("repo");
1240 let workdir = repo.join("src");
1241 fs::create_dir_all(&workdir).unwrap();
1242 fs::create_dir_all(repo.join(".git")).unwrap();
1243 let mem_dir = repo.join(".mermaid").join("memory");
1244 fs::create_dir_all(&mem_dir).unwrap();
1245 let fact = mem_dir.join("fact.md");
1246 fs::write(&fact, "the fact body").unwrap();
1247
1248 let roots = AllowedRoots::new(&workdir, None);
1249 let target = resolve_read_target(&roots, fact.to_str().unwrap()).unwrap();
1252 assert!(target.external.is_none(), "memory reads are not external");
1253 let (content, truncated) = read_one(target.root, target.rel).await.unwrap();
1254 assert!(!truncated);
1255 assert_eq!(content, "the fact body");
1256
1257 let stray = base.join("stray.txt");
1260 fs::write(&stray, "nope").unwrap();
1261 let target = resolve_read_target(&roots, stray.to_str().unwrap()).unwrap();
1262 assert_eq!(
1263 target.external.as_deref().map(|p| p.ends_with("stray.txt")),
1264 Some(true),
1265 "a path outside every root is external"
1266 );
1267
1268 let _ = fs::remove_dir_all(&base);
1269 }
1270
1271 #[test]
1272 fn resolve_in_roots_contains_to_workdir() {
1273 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
1274 let _ = fs::remove_dir_all(&root);
1275 fs::create_dir_all(root.join("sub")).unwrap();
1276 let roots = AllowedRoots::new(&root, None);
1277
1278 assert!(resolve_in_roots(&roots, "sub").is_ok());
1280 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
1281 let canon_root = fs::canonicalize(&root).unwrap();
1282 assert!(resolved.abs.starts_with(&canon_root));
1283
1284 assert!(resolve_in_roots(&roots, "../escape.txt").is_ok());
1286 let outside = std::env::temp_dir().join("definitely_outside.txt");
1287 let r = resolve_in_roots(&roots, &outside.display().to_string()).unwrap();
1288 assert!(r.abs.ends_with(Path::new("definitely_outside.txt")));
1289
1290 let _ = fs::remove_dir_all(&root);
1291 }
1292
1293 fn temp_root(name: &str) -> PathBuf {
1294 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
1295 let _ = fs::remove_dir_all(&p);
1296 fs::create_dir_all(&p).expect("create tmpdir");
1297 p
1298 }
1299
1300 #[tokio::test]
1301 async fn read_file_returns_content() {
1302 let dir = temp_root("read_ok");
1303 fs::write(dir.join("a.txt"), "hello").expect("write");
1304 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1305
1306 let tool = ReadFileTool;
1307 let outcome = tool
1308 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1309 .await;
1310 assert!(outcome.is_success(), "expected success: {outcome:?}");
1311 assert_eq!(outcome.output(), "hello");
1312 let _ = fs::remove_dir_all(&dir);
1313 }
1314
1315 #[tokio::test]
1316 async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
1317 let dir = temp_root("read_url");
1322 fs::write(dir.join("a.txt"), "hello").expect("write");
1323 for args in [
1324 serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1325 serde_json::json!({"path": "HTTP://example.com/x"}),
1326 serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1327 ] {
1328 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1329 let outcome = ReadFileTool.execute(args, ctx).await;
1330 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1331 let msg = outcome.error_message().unwrap_or_default();
1332 assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1333 }
1334 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1335 let outcome = ReadFileTool
1336 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1337 .await;
1338 assert!(outcome.is_success(), "plain local reads must still work");
1339 let _ = fs::remove_dir_all(&dir);
1340 }
1341
1342 #[tokio::test]
1343 async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1344 let dir = temp_root("read_dedup");
1345 fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1346
1347 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1349 let outcome = ReadFileTool
1350 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1351 .await;
1352 assert_eq!(outcome.output(), "line one\nline two");
1353
1354 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1357 let outcome = ReadFileTool
1358 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1359 .await;
1360 assert!(outcome.is_success());
1361 assert!(
1362 outcome
1363 .output()
1364 .contains("unchanged since your read earlier this turn"),
1365 "{}",
1366 outcome.output()
1367 );
1368 assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1369 assert!(
1370 !outcome.output().contains("line two"),
1371 "the body must not repeat: {}",
1372 outcome.output()
1373 );
1374
1375 fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1381 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1382 let outcome = ReadFileTool
1383 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1384 .await;
1385 assert_eq!(
1386 outcome.output(),
1387 "line one\nline two\nline three",
1388 "a changed file must read in full"
1389 );
1390
1391 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1393 let outcome = ReadFileTool
1394 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1395 .await;
1396 assert!(
1397 outcome.output().contains("unchanged since"),
1398 "{}",
1399 outcome.output()
1400 );
1401
1402 let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1406 let outcome = ReadFileTool
1407 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1408 .await;
1409 assert_eq!(outcome.output(), "line one\nline two\nline three");
1410
1411 let _ = fs::remove_dir_all(&dir);
1412 }
1413
1414 #[tokio::test]
1415 async fn read_file_missing_path_errors() {
1416 let dir = temp_root("read_missing_path");
1417 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1418 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1419 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1420 let _ = fs::remove_dir_all(&dir);
1421 }
1422
1423 #[tokio::test]
1424 async fn read_file_nonexistent_errors() {
1425 let dir = temp_root("read_nonex");
1426 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1427 let outcome = ReadFileTool
1428 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1429 .await;
1430 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1431 let _ = fs::remove_dir_all(&dir);
1432 }
1433
1434 #[tokio::test]
1435 async fn read_file_with_multiple_paths_joins_contents() {
1436 let dir = temp_root("read_multi");
1437 fs::write(dir.join("a.txt"), "alpha").expect("write");
1438 fs::write(dir.join("b.txt"), "beta").expect("write");
1439 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1440 let outcome = ReadFileTool
1441 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1442 .await;
1443 assert!(outcome.is_success(), "expected success: {outcome:?}");
1444 let output = outcome.output();
1445 assert!(output.contains("=== a.txt ==="));
1446 assert!(output.contains("alpha"));
1447 assert!(output.contains("=== b.txt ==="));
1448 assert!(output.contains("beta"));
1449 let _ = fs::remove_dir_all(&dir);
1450 }
1451
1452 #[tokio::test]
1453 async fn read_file_multi_aggregate_is_capped() {
1454 let dir = temp_root("read_aggregate_cap");
1457 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1458 fs::write(dir.join("a.txt"), &chunk).expect("write a");
1459 fs::write(dir.join("b.txt"), &chunk).expect("write b");
1460 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1461 let outcome = ReadFileTool
1462 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1463 .await;
1464 assert!(outcome.is_success(), "expected success: {outcome:?}");
1465 let output = outcome.output();
1466 assert!(
1467 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1468 "combined must be capped, got {} bytes",
1469 output.len()
1470 );
1471 assert!(
1472 output.contains("elided"),
1473 "expected aggregate head+tail elision marker"
1474 );
1475 match &outcome.metadata.detail {
1476 ToolMetadata::ReadFile { truncated, .. } => {
1477 assert!(*truncated, "aggregate truncation must set truncated")
1478 },
1479 other => panic!("expected ReadFile metadata, got {other:?}"),
1480 }
1481 let _ = fs::remove_dir_all(&dir);
1482 }
1483
1484 #[tokio::test]
1485 async fn write_file_elides_diff_for_oversized_existing_file() {
1486 let dir = temp_root("write_oversized_diff");
1489 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1490 fs::write(dir.join("big.txt"), &big).expect("write fixture");
1491 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1492 let outcome = WriteFileTool
1493 .execute(
1494 serde_json::json!({"path": "big.txt", "content": "small\n"}),
1495 ctx,
1496 )
1497 .await;
1498 assert!(outcome.is_success(), "expected success: {outcome:?}");
1499 let diff = outcome
1500 .metadata
1501 .display_diff
1502 .as_deref()
1503 .expect("display diff");
1504 assert!(
1505 diff.contains("diff preview skipped"),
1506 "expected elision marker, got: {diff}"
1507 );
1508 assert!(
1509 outcome.metadata.diff_truncated,
1510 "oversized diff must set diff_truncated"
1511 );
1512 match &outcome.metadata.detail {
1513 ToolMetadata::WriteFile { created, .. } => {
1514 assert_eq!(*created, Some(false), "existing file is not 'created'")
1515 },
1516 other => panic!("expected WriteFile metadata, got {other:?}"),
1517 }
1518 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1520 assert_eq!(written, "small\n");
1521 let _ = fs::remove_dir_all(&dir);
1522 }
1523
1524 #[tokio::test]
1525 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1526 let dir = temp_root("read_marker_content");
1530 fs::write(
1531 dir.join("a.txt"),
1532 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1533 )
1534 .expect("write");
1535 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1536
1537 let outcome = ReadFileTool
1538 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1539 .await;
1540 assert!(outcome.is_success(), "expected success: {outcome:?}");
1541 match &outcome.metadata.detail {
1542 ToolMetadata::ReadFile { truncated, .. } => assert!(
1543 !truncated,
1544 "a file whose content contains the marker must not be flagged truncated"
1545 ),
1546 other => panic!("expected ReadFile metadata, got {other:?}"),
1547 }
1548 let _ = fs::remove_dir_all(&dir);
1549 }
1550
1551 #[tokio::test]
1552 async fn read_file_respects_cancellation() {
1553 let dir = temp_root("read_cancel");
1554 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1559 ctx.token.cancel();
1560 let outcome = ReadFileTool
1561 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1562 .await;
1563 assert!(outcome.was_cancelled());
1564 let _ = fs::remove_dir_all(&dir);
1565 }
1566
1567 #[tokio::test]
1568 async fn write_file_creates_and_counts_lines() {
1569 let dir = temp_root("write_ok");
1570 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1571 let outcome = WriteFileTool
1572 .execute(
1573 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1574 ctx,
1575 )
1576 .await;
1577 assert!(outcome.is_success(), "expected success: {outcome:?}");
1578 assert!(outcome.output().contains("3 lines"));
1579 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1580 assert!(written.contains("line1"));
1581 let _ = fs::remove_dir_all(&dir);
1582 }
1583
1584 #[tokio::test]
1585 async fn concurrent_write_file_same_path_serializes_cleanly() {
1586 let dir = temp_root("write_race");
1590 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1591 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1592 let a = "AAAA\nAAAA\n";
1593 let b = "BBBB\nBBBB\n";
1594 let (o1, o2) = tokio::join!(
1595 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1596 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1597 );
1598 assert!(o1.is_success(), "first write failed: {o1:?}");
1599 assert!(o2.is_success(), "second write failed: {o2:?}");
1600 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1601 assert!(
1602 final_content == a || final_content == b,
1603 "file must be exactly one clean write, got {final_content:?}"
1604 );
1605 let _ = fs::remove_dir_all(&dir);
1606 }
1607
1608 #[tokio::test]
1609 async fn write_file_new_file_records_added_display_diff() {
1610 let dir = temp_root("write_new_diff");
1611 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1612 let outcome = WriteFileTool
1613 .execute(
1614 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1615 ctx,
1616 )
1617 .await;
1618 assert!(outcome.is_success(), "expected success: {outcome:?}");
1619 let diff = outcome
1620 .metadata
1621 .display_diff
1622 .as_deref()
1623 .expect("display diff");
1624 assert!(diff.contains("+ alpha"));
1625 assert!(diff.contains("+ beta"));
1626 assert!(
1628 !diff.contains("@@"),
1629 "diff should not carry hunk headers: {diff}"
1630 );
1631 assert!(!diff.contains("/dev/null"));
1632 let _ = fs::remove_dir_all(&dir);
1633 }
1634
1635 #[tokio::test]
1636 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1637 let dir = temp_root("write_existing_diff");
1638 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1639 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1640 let outcome = WriteFileTool
1641 .execute(
1642 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1643 ctx,
1644 )
1645 .await;
1646 assert!(outcome.is_success(), "expected success: {outcome:?}");
1647 let diff = outcome
1648 .metadata
1649 .display_diff
1650 .as_deref()
1651 .expect("display diff");
1652 assert!(diff.contains("- old"));
1653 assert!(diff.contains("+ new"));
1654 let _ = fs::remove_dir_all(&dir);
1655 }
1656
1657 #[tokio::test]
1658 async fn write_file_creates_parent_dirs() {
1659 let dir = temp_root("write_parents");
1660 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1661 let outcome = WriteFileTool
1662 .execute(
1663 serde_json::json!({
1664 "path": "sub/nested/out.txt",
1665 "content": "deep",
1666 }),
1667 ctx,
1668 )
1669 .await;
1670 assert!(outcome.is_success(), "expected success: {outcome:?}");
1671 assert!(dir.join("sub/nested/out.txt").exists());
1672 let _ = fs::remove_dir_all(&dir);
1673 }
1674
1675 #[tokio::test]
1676 async fn write_file_missing_content_errors() {
1677 let dir = temp_root("write_missing");
1678 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1679 let outcome = WriteFileTool
1680 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1681 .await;
1682 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1683 let _ = fs::remove_dir_all(&dir);
1684 }
1685
1686 #[tokio::test]
1693 async fn read_file_allows_absolute_path_outside_workdir() {
1694 let dir = temp_root("read_abs_escape");
1695 let external_dir = temp_root("read_abs_external");
1696 let external_file = external_dir.join("ext.txt");
1697 fs::write(&external_file, "external content").unwrap();
1698 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1699 let outcome = ReadFileTool
1700 .execute(
1701 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
1702 ctx,
1703 )
1704 .await;
1705 assert!(outcome.is_success(), "expected success: {outcome:?}");
1706 assert_eq!(outcome.output(), "external content");
1707 let _ = fs::remove_dir_all(&dir);
1708 let _ = fs::remove_dir_all(&external_dir);
1709 }
1710
1711 #[tokio::test]
1713 async fn read_file_accepts_absolute_path_inside_workdir() {
1714 let dir = temp_root("read_abs_inside");
1715 let file = dir.join("hello.txt");
1716 fs::write(&file, "ok").expect("write fixture");
1717 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1718 let outcome = ReadFileTool
1719 .execute(
1720 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1721 ctx,
1722 )
1723 .await;
1724 assert!(outcome.is_success(), "expected success: {outcome:?}");
1725 let _ = fs::remove_dir_all(&dir);
1726 }
1727
1728 #[tokio::test]
1730 async fn write_file_allows_relative_parent_and_external_path() {
1731 let base = temp_root("write_dotdot_escape");
1732 let dir = base.join("project");
1733 fs::create_dir_all(&dir).unwrap();
1734 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1735 let outcome = WriteFileTool
1736 .execute(
1737 serde_json::json!({
1738 "path": "../escape.txt",
1739 "content": "written outside",
1740 }),
1741 ctx,
1742 )
1743 .await;
1744 assert!(
1745 outcome.is_success(),
1746 "expected success for write to ../escape.txt"
1747 );
1748 let written = base.join("escape.txt");
1749 assert!(written.exists());
1750 assert_eq!(fs::read_to_string(&written).unwrap(), "written outside");
1751 let _ = fs::remove_dir_all(&base);
1752 }
1753
1754 #[tokio::test]
1759 async fn create_directory_allows_absolute_path_outside_workdir() {
1760 let dir = temp_root("mkdir_abs_proj");
1761 let target = std::env::temp_dir().join(format!("mermaid_fs_target_{}", std::process::id()));
1762 let _ = fs::remove_dir_all(&target);
1763 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1764 let outcome = CreateDirectoryTool
1765 .execute(
1766 serde_json::json!({"path": target.to_string_lossy().to_string()}),
1767 ctx,
1768 )
1769 .await;
1770 assert!(outcome.is_success(), "expected success: {outcome:?}");
1771 assert!(target.exists());
1772 let _ = fs::remove_dir_all(&target);
1773 let _ = fs::remove_dir_all(&dir);
1774 }
1775
1776 fn scratch_ctx(
1782 mode: mermaid_runtime::SafetyMode,
1783 workdir: PathBuf,
1784 scratchpad: Option<PathBuf>,
1785 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1786 let mut config = mermaid_domain::Config::default();
1787 config.safety.mode = mode;
1788 let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
1789 TurnId(1),
1790 ToolCallId(1),
1791 workdir,
1792 config,
1793 );
1794 ctx.scratchpad = scratchpad;
1795 (ctx, rx)
1796 }
1797
1798 fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1800 let base = std::env::temp_dir().join(format!(
1801 "mermaid_fs_scratch_{}_{}",
1802 name,
1803 std::process::id()
1804 ));
1805 let _ = fs::remove_dir_all(&base);
1806 let project = base.join("project");
1807 let scratch = base.join("scratch");
1808 fs::create_dir_all(&project).unwrap();
1809 fs::create_dir_all(&scratch).unwrap();
1810 (project, scratch)
1811 }
1812
1813 fn any_checkpoint_mentions(marker: &str) -> bool {
1817 let Ok(data) = mermaid_runtime::data_dir() else {
1818 return false;
1819 };
1820 let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1821 return false;
1822 };
1823 entries.flatten().any(|entry| {
1824 fs::read_to_string(entry.path().join("manifest.json"))
1825 .is_ok_and(|manifest| manifest.contains(marker))
1826 })
1827 }
1828
1829 #[tokio::test]
1832 async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1833 let (project, scratch) = scratch_fixture("ungated");
1834 let marker = scratch.display().to_string();
1835
1836 let file = scratch.join("notes.txt");
1838 let (ctx, _rx) = scratch_ctx(
1839 mermaid_runtime::SafetyMode::Ask,
1840 project.clone(),
1841 Some(scratch.clone()),
1842 );
1843 let outcome = WriteFileTool
1844 .execute(
1845 serde_json::json!({
1846 "path": file.to_str().unwrap(),
1847 "content": "scratch note\n",
1848 }),
1849 ctx,
1850 )
1851 .await;
1852 assert!(outcome.is_success(), "scratch write: {outcome:?}");
1853 assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1854
1855 let subdir = scratch.join("work/area");
1857 let (ctx, _rx) = scratch_ctx(
1858 mermaid_runtime::SafetyMode::Ask,
1859 project.clone(),
1860 Some(scratch.clone()),
1861 );
1862 let outcome = CreateDirectoryTool
1863 .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1864 .await;
1865 assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1866 assert!(subdir.is_dir());
1867
1868 let (ctx, _rx) = scratch_ctx(
1870 mermaid_runtime::SafetyMode::Ask,
1871 project.clone(),
1872 Some(scratch.clone()),
1873 );
1874 let outcome = DeleteFileTool
1875 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1876 .await;
1877 assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1878 assert!(!file.exists());
1879
1880 assert!(
1882 !any_checkpoint_mentions(&marker),
1883 "scratch mutation must not create a checkpoint"
1884 );
1885 let _ = fs::remove_dir_all(project.parent().unwrap());
1886 }
1887
1888 #[tokio::test]
1891 async fn scratch_mutation_blocked_in_read_only() {
1892 let (project, scratch) = scratch_fixture("readonly");
1893 let file = scratch.join("blocked.txt");
1894 let (ctx, _rx) = scratch_ctx(
1895 mermaid_runtime::SafetyMode::ReadOnly,
1896 project.clone(),
1897 Some(scratch.clone()),
1898 );
1899 let outcome = WriteFileTool
1900 .execute(
1901 serde_json::json!({
1902 "path": file.to_str().unwrap(),
1903 "content": "nope",
1904 }),
1905 ctx,
1906 )
1907 .await;
1908 let error = outcome.error_message().expect("expected block");
1909 assert!(
1910 error.contains("blocked by policy"),
1911 "expected policy block, got: {error}"
1912 );
1913 assert!(!file.exists());
1914 let _ = fs::remove_dir_all(project.parent().unwrap());
1915 }
1916
1917 #[tokio::test]
1919 async fn write_outside_both_roots_requires_approval_in_ask_mode() {
1920 let (project, scratch) = scratch_fixture("outside");
1921 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
1922 let (ctx, _rx) = scratch_ctx(
1923 mermaid_runtime::SafetyMode::Ask,
1924 project.clone(),
1925 Some(scratch.clone()),
1926 );
1927 let outcome = WriteFileTool
1928 .execute(
1929 serde_json::json!({
1930 "path": outside.to_str().unwrap(),
1931 "content": "should require approval",
1932 }),
1933 ctx,
1934 )
1935 .await;
1936 let error = outcome.error_message().expect("expected approval required");
1937 assert!(
1938 error.contains("Approval required"),
1939 "expected approval block, got: {error}"
1940 );
1941 assert!(!outside.exists());
1942 let _ = fs::remove_dir_all(project.parent().unwrap());
1943 }
1944
1945 #[tokio::test]
1947 async fn read_file_reads_from_scratchpad() {
1948 let (project, scratch) = scratch_fixture("read");
1949 let file = scratch.join("stash.txt");
1950 fs::write(&file, "stashed").unwrap();
1951 let (ctx, _rx) = scratch_ctx(
1952 mermaid_runtime::SafetyMode::Ask,
1953 project.clone(),
1954 Some(scratch.clone()),
1955 );
1956 let outcome = ReadFileTool
1957 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1958 .await;
1959 assert!(outcome.is_success(), "scratch read: {outcome:?}");
1960 assert_eq!(outcome.output(), "stashed");
1961 let _ = fs::remove_dir_all(project.parent().unwrap());
1962 }
1963
1964 #[tokio::test]
1965 async fn edit_file_replaces_target_content_successfully() {
1966 let (project, _scratch) = scratch_fixture("edit_success");
1967 let file = project.join("src").join("main.rs");
1968 fs::create_dir_all(file.parent().unwrap()).unwrap();
1969 fs::write(&file, "fn main() {\n println!(\"old\");\n}\n").unwrap();
1970
1971 let (ctx, _rx) = scratch_ctx(
1972 mermaid_runtime::SafetyMode::FullAccess,
1973 project.clone(),
1974 None,
1975 );
1976 let outcome = EditFileTool
1977 .execute(
1978 serde_json::json!({
1979 "path": "src/main.rs",
1980 "target_content": " println!(\"old\");",
1981 "replacement_content": " println!(\"new\");",
1982 }),
1983 ctx,
1984 )
1985 .await;
1986
1987 assert!(outcome.is_success(), "edit outcome: {outcome:?}");
1988 let new_content = fs::read_to_string(&file).unwrap();
1989 assert_eq!(new_content, "fn main() {\n println!(\"new\");\n}\n");
1990 let diff = outcome
1991 .metadata
1992 .display_diff
1993 .as_deref()
1994 .expect("display diff");
1995 assert!(diff.contains("- println!(\"old\");"));
1996 assert!(diff.contains("+ println!(\"new\");"));
1997 let _ = fs::remove_dir_all(project.parent().unwrap());
1998 }
1999
2000 #[tokio::test]
2001 async fn edit_file_target_not_found_returns_error() {
2002 let (project, _scratch) = scratch_fixture("edit_not_found");
2003 let file = project.join("hello.txt");
2004 fs::write(&file, "line 1\nline 2\n").unwrap();
2005
2006 let (ctx, _rx) = scratch_ctx(
2007 mermaid_runtime::SafetyMode::FullAccess,
2008 project.clone(),
2009 None,
2010 );
2011 let outcome = EditFileTool
2012 .execute(
2013 serde_json::json!({
2014 "path": "hello.txt",
2015 "target_content": "nonexistent text",
2016 "replacement_content": "replacement",
2017 }),
2018 ctx,
2019 )
2020 .await;
2021
2022 assert!(!outcome.is_success());
2023 let err = outcome.error_message().unwrap();
2024 assert!(err.contains("could not find target_content"), "{err}");
2025 let _ = fs::remove_dir_all(project.parent().unwrap());
2026 }
2027
2028 #[tokio::test]
2029 async fn edit_file_ambiguous_target_errors_unless_allow_multiple() {
2030 let (project, _scratch) = scratch_fixture("edit_ambig");
2031 let file = project.join("dup.txt");
2032 fs::write(&file, "foo\nbar\nfoo\n").unwrap();
2033
2034 let (ctx, _rx) = scratch_ctx(
2035 mermaid_runtime::SafetyMode::FullAccess,
2036 project.clone(),
2037 None,
2038 );
2039 let outcome_ambig = EditFileTool
2040 .execute(
2041 serde_json::json!({
2042 "path": "dup.txt",
2043 "target_content": "foo",
2044 "replacement_content": "baz",
2045 }),
2046 ctx,
2047 )
2048 .await;
2049 assert!(!outcome_ambig.is_success());
2050 let err = outcome_ambig.error_message().unwrap();
2051 assert!(err.contains("found 2 times"), "{err}");
2052
2053 let (ctx2, _rx2) = scratch_ctx(
2054 mermaid_runtime::SafetyMode::FullAccess,
2055 project.clone(),
2056 None,
2057 );
2058 let outcome_allow = EditFileTool
2059 .execute(
2060 serde_json::json!({
2061 "path": "dup.txt",
2062 "target_content": "foo",
2063 "replacement_content": "baz",
2064 "allow_multiple": true,
2065 }),
2066 ctx2,
2067 )
2068 .await;
2069 assert!(outcome_allow.is_success());
2070 let new_content = fs::read_to_string(&file).unwrap();
2071 assert_eq!(new_content, "baz\nbar\nbaz\n");
2072 let _ = fs::remove_dir_all(project.parent().unwrap());
2073 }
2074
2075 #[tokio::test]
2076 async fn edit_file_fuzzy_whitespace_matching_succeeds() {
2077 let (project, _scratch) = scratch_fixture("edit_fuzzy");
2078 let file = project.join("fuzzy.txt");
2079 fs::write(&file, "start\n loose_whitespace(); \nend\n").unwrap();
2080
2081 let (ctx, _rx) = scratch_ctx(
2082 mermaid_runtime::SafetyMode::FullAccess,
2083 project.clone(),
2084 None,
2085 );
2086 let outcome = EditFileTool
2087 .execute(
2088 serde_json::json!({
2089 "path": "fuzzy.txt",
2090 "target_content": "loose_whitespace();",
2091 "replacement_content": " tight_whitespace();",
2092 }),
2093 ctx,
2094 )
2095 .await;
2096 assert!(outcome.is_success());
2097 assert!(outcome.output().contains("fuzzy"));
2098 let new_content = fs::read_to_string(&file).unwrap();
2099 assert_eq!(new_content, "start\n tight_whitespace();\nend\n");
2100 let _ = fs::remove_dir_all(project.parent().unwrap());
2101 }
2102
2103 fn external_read_fixture(name: &str) -> (PathBuf, PathBuf) {
2107 let workdir = temp_root(&format!("{name}_project"));
2108 let external_dir = temp_root(&format!("{name}_external"));
2109 let external_file = external_dir.join("ext.txt");
2110 fs::write(&external_file, "external content").unwrap();
2111 (workdir, external_file)
2112 }
2113
2114 fn ctx_in_mode(
2115 mode: mermaid_runtime::SafetyMode,
2116 workdir: PathBuf,
2117 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2118 let mut config = mermaid_domain::Config::default();
2119 config.safety.mode = mode;
2120 crate::providers::ctx::test_exec_context_with_config(
2121 TurnId(1),
2122 ToolCallId(1),
2123 workdir,
2124 config,
2125 )
2126 }
2127
2128 async fn read_external(ctx: ExecContext, external_file: &Path) -> ToolOutcome {
2129 ReadFileTool
2130 .execute(
2131 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
2132 ctx,
2133 )
2134 .await
2135 }
2136
2137 #[tokio::test]
2141 async fn read_outside_the_project_is_denied_under_the_read_only_floor() {
2142 for mode in [
2143 mermaid_runtime::SafetyMode::ReadOnly,
2144 mermaid_runtime::SafetyMode::Plan,
2145 ] {
2146 let (workdir, external_file) = external_read_fixture("read_ext_readonly");
2147 let (ctx, _rx) = ctx_in_mode(mode, workdir.clone());
2148 let outcome = read_external(ctx, &external_file).await;
2149 assert_eq!(
2150 outcome.status,
2151 mermaid_domain::ToolStatus::Error,
2152 "{mode:?} must deny an external read: {outcome:?}"
2153 );
2154 assert!(
2155 !outcome.output().contains("external content"),
2156 "{mode:?} leaked the file body: {}",
2157 outcome.output()
2158 );
2159 let _ = fs::remove_dir_all(&workdir);
2160 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2161 }
2162 }
2163
2164 #[tokio::test]
2167 async fn read_inside_the_project_stays_ungated_in_read_only_mode() {
2168 let workdir = temp_root("read_inside_readonly");
2169 fs::write(workdir.join("notes.md"), "project content").unwrap();
2170 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2171 let outcome = ReadFileTool
2172 .execute(serde_json::json!({"path": "notes.md"}), ctx)
2173 .await;
2174 assert_eq!(
2175 outcome.status,
2176 mermaid_domain::ToolStatus::Success,
2177 "{outcome:?}"
2178 );
2179 assert_eq!(outcome.output(), "project content");
2180 let _ = fs::remove_dir_all(&workdir);
2181 }
2182
2183 #[tokio::test]
2187 async fn read_outside_the_project_prompts_in_ask_mode_scoped_to_its_directory() {
2188 let (workdir, external_file) = external_read_fixture("read_ext_ask");
2189 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2190 let broker = crate::providers::ApprovalBroker::new(tx);
2191 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2192 ctx.approval = Some(broker.clone());
2193 let file_for_task = external_file.clone();
2194 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2195
2196 let (call_id, tool, scope) = match rx.recv().await.expect("approval requested") {
2197 mermaid_domain::Msg::ApprovalRequested {
2198 call_id,
2199 tool,
2200 allowlist_scope,
2201 ..
2202 } => (call_id, tool, allowlist_scope),
2203 other => panic!("expected ApprovalRequested, got {other:?}"),
2204 };
2205 assert_eq!(tool, "read_file");
2206 let expected_dir = external_file.canonicalize().unwrap();
2207 let expected_dir = expected_dir.parent().unwrap();
2208 assert_eq!(
2209 scope,
2210 format!("read_file:{}", expected_dir.display()),
2211 "external reads are allowlisted per directory"
2212 );
2213
2214 broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
2215 let outcome = handle.await.unwrap();
2216 assert_eq!(
2217 outcome.status,
2218 mermaid_domain::ToolStatus::Success,
2219 "{outcome:?}"
2220 );
2221 assert_eq!(outcome.output(), "external content");
2222 let _ = fs::remove_dir_all(&workdir);
2223 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2224 }
2225
2226 #[tokio::test]
2228 async fn read_outside_the_project_denied_by_the_user_is_an_error() {
2229 let (workdir, external_file) = external_read_fixture("read_ext_deny");
2230 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2231 let broker = crate::providers::ApprovalBroker::new(tx);
2232 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2233 ctx.approval = Some(broker.clone());
2234 let file_for_task = external_file.clone();
2235 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2236 let call_id = match rx.recv().await.expect("approval requested") {
2237 mermaid_domain::Msg::ApprovalRequested { call_id, .. } => call_id,
2238 other => panic!("expected ApprovalRequested, got {other:?}"),
2239 };
2240 broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
2241 let outcome = handle.await.unwrap();
2242 assert_eq!(
2243 outcome.status,
2244 mermaid_domain::ToolStatus::Error,
2245 "{outcome:?}"
2246 );
2247 assert!(!outcome.output().contains("external content"));
2248 let _ = fs::remove_dir_all(&workdir);
2249 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2250 }
2251
2252 #[cfg(unix)]
2256 #[tokio::test]
2257 async fn a_symlink_inside_the_project_that_points_outside_is_gated() {
2258 let (workdir, external_file) = external_read_fixture("read_ext_symlink");
2259 std::os::unix::fs::symlink(&external_file, workdir.join("innocent.txt")).unwrap();
2260 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2261 let outcome = ReadFileTool
2262 .execute(serde_json::json!({"path": "innocent.txt"}), ctx)
2263 .await;
2264 assert_eq!(
2265 outcome.status,
2266 mermaid_domain::ToolStatus::Error,
2267 "{outcome:?}"
2268 );
2269 assert!(!outcome.output().contains("external content"));
2270 let _ = fs::remove_dir_all(&workdir);
2271 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2272 }
2273
2274 #[tokio::test]
2279 async fn write_outside_the_project_is_not_silently_allowed_in_auto_mode() {
2280 let (project, scratch) = scratch_fixture("outside_auto");
2281 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2282 let (ctx, _rx) = scratch_ctx(
2283 mermaid_runtime::SafetyMode::Auto,
2284 project.clone(),
2285 Some(scratch.clone()),
2286 );
2287 let outcome = WriteFileTool
2288 .execute(
2289 serde_json::json!({
2290 "path": outside.to_str().unwrap(),
2291 "content": "must not land unasked",
2292 }),
2293 ctx,
2294 )
2295 .await;
2296 assert_eq!(
2297 outcome.status,
2298 mermaid_domain::ToolStatus::Error,
2299 "auto mode must escalate an external write: {outcome:?}"
2300 );
2301 assert!(!outside.exists(), "the external file was written anyway");
2302 let _ = fs::remove_dir_all(project.parent().unwrap());
2303 }
2304
2305 #[tokio::test]
2308 async fn write_inside_the_project_stays_allowed_in_auto_mode() {
2309 let (project, scratch) = scratch_fixture("inside_auto");
2310 let (ctx, _rx) = scratch_ctx(
2311 mermaid_runtime::SafetyMode::Auto,
2312 project.clone(),
2313 Some(scratch.clone()),
2314 );
2315 let outcome = WriteFileTool
2316 .execute(
2317 serde_json::json!({"path": "inside.txt", "content": "fine"}),
2318 ctx,
2319 )
2320 .await;
2321 assert_eq!(
2322 outcome.status,
2323 mermaid_domain::ToolStatus::Success,
2324 "{outcome:?}"
2325 );
2326 assert_eq!(
2327 fs::read_to_string(project.join("inside.txt")).unwrap(),
2328 "fine"
2329 );
2330 let _ = fs::remove_dir_all(project.parent().unwrap());
2331 }
2332}