Skip to main content

mermaid_cli/providers/tool/
filesystem.rs

1//! Filesystem tools ported to `ToolExecutor`.
2//!
3//! This is the proof-of-pattern tool impl for C3: `ReadFileTool` and
4//! `WriteFileTool`. They hook the `ExecContext::token` so Ctrl+C
5//! cancels mid-read (relevant for large files on slow storage), and
6//! they emit `ProgressEvent::Status` breadcrumbs for multi-file
7//! operations the old code couldn't surface without an observer
8//! callback.
9//!
10//! The implementations don't try to out-clever the existing tool
11//! behavior in `src/agents/filesystem.rs`. Same semantics, same error
12//! shapes — just wrapped in the new trait so future tools only have
13//! to learn this surface.
14
15use 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
30/// Small helper for building a `ToolDefinition` with a typical
31/// JSON-schema-shaped `input_schema`. Keeps the per-tool definitions
32/// readable.
33fn 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
41/// Aggregate cap for a multi-file `read_file` result (#F45). Each file is
42/// individually bounded at `MAX_RESPONSE_CHARS` by `read_one`, but a batch of up
43/// to `MAX_BATCH_TOOL_ITEMS` files could otherwise sum to ~12.8 MB in a single
44/// tool result — far past any sane model-context budget. This bounds the
45/// combined total; single-file reads are already bounded and unaffected.
46const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
47
48/// Entries kept for same-turn duplicate-read suppression, across all live
49/// scopes. Hashes only — never content — so the bound is about map hygiene,
50/// not memory pressure.
51const READ_DEDUP_CAP: usize = 128;
52
53/// One remembered read: which context read which path, in which turn, and
54/// the content hash that proves the repeat is byte-identical.
55struct ReadDedupEntry {
56    scope: String,
57    path: String,
58    turn: u64,
59    hash: [u8; 32],
60    line_count: usize,
61}
62
63/// Same-turn duplicate reads, process-global (mirrors `web.rs`'s snapshot
64/// store). The `20260806` field logs show the same file read up to 14 times
65/// per session at full length — sometimes twice within one turn, where the
66/// earlier result is by construction still in the model's request. Only
67/// that provably-safe window is deduped: across turns a re-read may be a
68/// legitimate refresh (post-edit, post-compaction). Equality is proven by
69/// content hash — not mtime, which lies on some drives — so a change made
70/// by ANY path (`write_file`, `apply_patch`, `execute_command`, the user's
71/// editor) yields full content again with no invalidation hooks to forget.
72static READ_DEDUP: std::sync::OnceLock<
73    std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
74> = std::sync::OnceLock::new();
75
76/// The identity a dedup entry belongs to. Session/task ids separate
77/// concurrent contexts (a subagent's context is not the parent's — each
78/// must receive its own full read); the workdir separates anonymous test
79/// harness contexts, which reuse small turn ids.
80fn 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
89/// Returns the short reuse note when `content` is byte-identical to what an
90/// earlier read of `path` already returned THIS turn; otherwise records the
91/// read and returns `None` (full content flows). The note names the line
92/// count and the recovery paths so the model is taught, not stonewalled.
93fn 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
132/// `read_file` — read one or more files and return their contents
133/// joined with section markers.
134pub 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            // A path outside every allowed root is an external side effect:
189            // it goes through the policy gate before a byte is read, exactly
190            // as an external write does.
191            if let Some(abs) = &target.external
192                && let Some(blocked) = external_read_gate(&ctx, raw_path, abs).await
193            {
194                return blocked;
195            }
196            // Race the file read against the turn's cancel token. If
197            // the user Ctrl+C's mid-read, we bail immediately.
198            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                            // A byte-identical repeat of a read this same turn
207                            // collapses to a short reuse note — the earlier
208                            // result is still in the model's request. The note
209                            // is not a truncation: nothing was cut that isn't
210                            // already present in full.
211                            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        // F45: bound the COMBINED multi-file result. Each file is already capped
238        // at MAX_RESPONSE_CHARS by read_one, but a batch of files can still sum to
239        // ~12.8 MB in one tool result — past any sane context budget. Only the
240        // multi-file accumulation needs this (single-file output is already
241        // bounded); truncate_middle keeps the head AND tail with an elision marker.
242        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        // The REAL truncation flag from the bounded read — not a sniff for the
251        // marker string, which a file containing that literal text would
252        // falsely trip (#78).
253        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
277/// `delete_file` — unlink a file. Errors on directories (use
278/// `execute_command rm -rf` for those — the model shouldn't be
279/// blowing away directories as a routine op).
280pub 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        // Serialize writers to this canonical path: sibling tool calls in the same
336        // turn run concurrently, so without this the checkpoint + delete could race
337        // another writer to the same file. Distinct paths still overlap. Raced
338        // against cancellation so a contended lock stays Ctrl+C-responsive.
339        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        // Scratchpad files are session-private and ephemeral — never
345        // checkpointed into the project's restore history.
346        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
390/// `create_directory` — `mkdir -p` semantics.
391pub 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        // Serialize writers to this canonical path (see delete_file). mkdir -p is
447        // idempotent, but a uniform gate keeps ordering consistent and cheap.
448        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        // Scratchpad dirs are session-private and ephemeral — never checkpointed.
454        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
498/// `write_file` — write a single file, creating parent dirs as needed.
499pub 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        // `rel` is the root-relative name for the confined fd write (the actual
533        // byte path).
534        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        // Serialize writers to this canonical path: two write_file/edit calls to
564        // the same file in one turn run concurrently, so without this the last
565        // atomic rename silently wins (lost update). Distinct paths still overlap.
566        // The owned guard is Send and held across the spawn_blocking below.
567        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        // Scratchpad files are session-private and ephemeral — never checkpointed.
573        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            // The prior-content read (for the display diff) now happens INSIDE
596            // this blocking job and BOUNDED (#F44/RC-L) — never a synchronous
597            // unbounded `read_to_string` on the async worker thread.
598            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
638/// `edit_file` — precise search-and-replace editing on a single file.
639pub 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
805// ─── helpers ────────────────────────────────────────────────────────
806
807fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
808    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
809    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
834/// `read_file` reads the local filesystem, but models under a web-gated
835/// safety mode were observed pointing it at `https://` URLs and treating the
836/// result as a fetch — and the path-resolution error that came back said
837/// nothing about the actual mistake. Name the mistake and the right tool;
838/// whether `web_fetch` is available is then that tool's own story to tell.
839fn 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
854/// Read-only carve-out for memory facts. Global and project-private memory
855/// live under the OS data dir — outside both allowed roots — and the memory
856/// index tells the model to `read_file` the fact's path, so reads resolve
857/// against the memory roots too. Absolute paths only, with the same
858/// canonical (symlink-resolving) containment as the scratchpad arm. The
859/// write tools never consult this: memory mutation goes through the `memory`
860/// tool, where the policy gate can see it.
861fn 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
875/// Where a `read_file` target lands once the resolver and the memory-root
876/// fallback have spoken.
877struct ReadTarget {
878    root: PathBuf,
879    rel: PathBuf,
880    /// `Some(abs)` when the target sits outside every allowed root and must
881    /// clear [`external_read_gate`] before it is opened.
882    external: Option<PathBuf>,
883}
884
885/// Resolve a read target through the canonical containment resolver, and
886/// answer its three-way verdict: project and scratchpad reads are ungated;
887/// durable-memory reads are ungated too (memory is agent-owned by design and
888/// lives outside the project); anything else outside the roots is external and
889/// carries its absolute path for the gate.
890///
891/// `Err` only when the path cannot be resolved at all (a workdir that will not
892/// canonicalize, or no existing ancestor) and is not a memory path either.
893fn 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
931/// Gate a read of `abs`, a path outside the project and the scratchpad.
932///
933/// Filed as `ToolCategory::ExternalDirectory` — an external side effect, the
934/// same class an out-of-project `working_dir` gets from the exec tool — so
935/// `ask` prompts (allowlistable per directory), `auto` consults the intent
936/// classifier, `full_access` proceeds, and read-only mode denies. Not replayable: a read has nothing to replay, so a
937/// headless `ask` session refuses it unless untrusted tools were allowed.
938///
939/// Returns the blocking outcome, or `None` to proceed.
940async 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
961/// Read one file (bounded) from `rel` beneath `root`. Returns the (possibly
962/// marker-footed) text and the REAL truncation flag from the bounded read, so
963/// the caller propagates that rather than sniffing the output for the marker
964/// string — which a file whose own content contains that literal text would
965/// otherwise falsely trip (#78).
966///
967/// The root-relative path feeds the confined fd read, so the bytes come from
968/// the inode the kernel resolved under `RESOLVE_BENEATH` rather than whatever
969/// a concurrently-swapped symlink now points at (#77).
970async 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        // Bounded read: never pull more than the cap (+1 probe byte) into RAM,
974        // so a model pointing `read_file` at a multi-gigabyte file can't OOM the
975        // process — a full read would have slurped the whole thing first (#15).
976        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            // Char-boundary-safe truncation with a marker footer.
980            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
991/// Write `content` to `rel` beneath `root` (the project workdir or the session
992/// scratchpad) through the symlink-confined *atomic* writer, creating parent
993/// dirs the same confined way. The bytes are written to a temp and
994/// `renameat`-swapped over the target, all beneath the directory fd the kernel
995/// resolved under `RESOLVE_BENEATH`: a parent dir swapped for an escaping
996/// symlink can't redirect the write (#77), and a crash/kill/disk-full
997/// mid-write leaves the previous file intact rather than a truncated or
998/// half-written one.
999fn 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
1015/// Write `content` and build the display diff against the prior file in ONE
1016/// blocking job (#F44/RC-L). The prior content is read BOUNDED via
1017/// [`mermaid_model::utils::read_file_capped`] — overwriting a multi-gigabyte file must
1018/// not slurp it into RAM on the async worker just to render a diff. A prior file
1019/// larger than the read cap (or otherwise unreadable) is elided from the diff
1020/// rather than read whole.
1021fn 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            // Existing file is past the read cap — don't pull it all into RAM.
1031            Ok((_, true)) => (String::new(), false, true),
1032            // Missing file → a fresh create; the diff shows the whole content added.
1033            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1034            // Any other read error: don't fail the write over a diff preview.
1035            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
1092/// Outcome of gating a file mutation.
1093pub(super) enum MutationGate {
1094    /// Blocked — return this outcome verbatim. Boxed to keep the enum small.
1095    Blocked(Box<ToolOutcome>),
1096    Proceed,
1097}
1098
1099/// Gate a file mutation by where its target landed.
1100///
1101/// `containment` is the resolver's verdict for the path, matched here so that
1102/// every mutating tool answers the three-way question in one place:
1103/// - `Project`: an ordinary edit (`ToolCategory::Edit`).
1104/// - `Scratchpad`: the gate downgrades an `Ask`/`Classify` to proceed — scratch
1105///   files are session-private and ephemeral — while read-only mode and `Deny`
1106///   overrides still block it.
1107/// - `External`: a path outside both roots is an external side effect
1108///   (`ToolCategory::ExternalDirectory`), so it prompts in `ask`, goes through
1109///   the intent classifier in `auto`, and is denied by the read-only floor —
1110///   the same treatment an out-of-project `working_dir` gets from the exec
1111///   tool. Before this, an external write classified like an in-project one
1112///   and `auto` mode allowed it unasked.
1113pub(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    // File mutations are replayable: an Ask decision checkpoints, records an
1130    // approval, and blocks (handled inside the gate).
1131    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    /// Memory facts live outside the project/scratchpad roots and the index
1206    /// tells the model to `read_file` them — reads must resolve against the
1207    /// memory roots (here the `ProjectShared` root, reached by putting the
1208    /// workdir in a subdir of the git root), while unrelated outside paths
1209    /// stay rejected.
1210    #[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        // A memory path sits outside the project yet resolves ungated: memory
1225        // is agent-owned by design.
1226        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        // A stray path outside every root resolves too, but carries its
1233        // absolute path for the gate: `execute` must not read it unasked.
1234        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        // In-root existing + not-yet-existing targets resolve inside root.
1254        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        // External paths and relative .. escapes resolve successfully.
1260        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        // Observed in the field: a model under a web-gated safety mode fed
1293        // `read_file` an https:// URL and treated the reply as a fetch. The
1294        // rejection must name the right tool — and a plain local read next to
1295        // it must keep working (the guard cannot overmatch).
1296        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        // First read: full content.
1323        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        // Byte-identical repeat in the SAME turn: a short reuse note, not
1330        // the body again — the earlier result rides the same request.
1331        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        // Matched pair (a): the file CHANGED on disk — by any writer; here a
1351        // direct fs write stands in for write_file / apply_patch /
1352        // execute_command — so the same-turn re-read returns full content.
1353        // Content-hash equality is the invalidation; there is no hook to
1354        // forget.
1355        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        // ...and the byte-identical repeat of THAT read collapses again.
1367        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        // Matched pair (b): a LATER turn always reads in full — a cross-turn
1378        // re-read may be a legitimate refresh (post-compaction, post-edit)
1379        // and is never suppressed.
1380        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        // F45: many files in one call can't blow past the aggregate cap. Each
1430        // file is under the per-file cap, but their sum exceeds the aggregate.
1431        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        // F44: overwriting a file larger than the read cap must NOT slurp it into
1462        // RAM for a diff — the diff is elided with a marker instead.
1463        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        // The file was actually overwritten despite the elided diff.
1494        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        // #78: a small file whose own content contains the truncation-marker
1502        // string must NOT be reported as truncated — the flag comes from the
1503        // bounded read now, not a substring sniff of the output.
1504        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        // Write a huge file so the read is slow enough to race cancel.
1530        // Actually spawn_blocking on read is fast on tmpfs — this test
1531        // just verifies the select! arm compiles + the token trips
1532        // the cancel path when pre-cancelled.
1533        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        // The per-path write gate must let two writes to the same file in one turn
1562        // both succeed and leave the file as exactly one clean write (never a
1563        // corrupt interleave), and must not deadlock.
1564        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        // No unified-diff header clutter (`---`/`+++`/`@@`).
1602        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    // ─── F10: absolute-path block ───────────────────────────────────
1662
1663    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1664    /// must fail with a clear "outside the project" error. The tool
1665    /// schema advertises this contract; before F10 it was a lie.
1666    /// Reading an absolute path outside workdir succeeds.
1667    #[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    /// Absolute path that lives INSIDE the workdir is allowed.
1687    #[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    /// Relative `..`-escape and external paths are supported.
1704    #[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    /// `create_directory` needs the lexical-normalization fallback
1730    /// because the target doesn't exist yet (can't canonicalize).
1731    /// Verify the escape check still fires for non-existent targets.
1732    /// `create_directory` creates directories outside workdir when called.
1733    #[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    // ─── session-scratchpad dual root ────────────────────────────────
1752
1753    /// Build an `ExecContext` with an explicit safety mode, NO approval
1754    /// broker, and (optionally) a materialized scratchpad. Unlike
1755    /// `test_exec_context` (pinned to `FullAccess`) this exercises the gate.
1756    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    /// Project + scratch fixture pair with a unique, greppable name.
1774    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    /// True when any checkpoint manifest on disk references `marker`. The
1789    /// fixture paths are unique per test+pid, so a hit can only come from
1790    /// the mutation under test.
1791    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    /// Scratchpad mutations proceed in Ask mode with NO approval broker
1805    /// bound (the gate is bypassed entirely) and never take a checkpoint.
1806    #[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        // write_file into the scratchpad via absolute path.
1812        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        // create_directory inside the scratchpad.
1831        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        // delete_file inside the scratchpad.
1844        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        // None of the mutations checkpointed the ephemeral scratch paths.
1856        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    /// `ReadOnly` still blocks scratchpad mutations — the bypass only skips
1864    /// the approval flow, never the mode's mutation ban.
1865    #[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    /// A path outside scratchpad in Ask mode requires approval.
1893    #[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    /// `read_file` follows a materialized scratchpad too.
1921    #[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    // ─── Reads outside the project go through the policy gate ─────────────
2079
2080    /// A project dir plus a file that sits outside every allowed root.
2081    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    /// The read-only floor: an out-of-project read is an external side effect,
2113    /// denied in `read_only`. Before the gate, `read_file` handed back
2114    /// `~/.ssh/id_rsa` in every mode without a prompt.
2115    #[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    /// Inside the project nothing changes: a `read_only` session still reads
2135    /// its own files without a prompt.
2136    #[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    /// `ask` mode prompts, and the prompt's "don't ask again" scope is the
2154    /// file's directory — never the whole tool, so approving one external
2155    /// read cannot silently cover a credential file later in the session.
2156    #[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    /// The user's "No" is final: the read fails and the body never leaves disk.
2197    #[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    /// A symlink planted inside the project that resolves outside it is an
2223    /// external read: the resolver canonicalizes through the link, so the
2224    /// gate sees the real target, not the friendly-looking relative path.
2225    #[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    /// `auto` mode classifies external writes instead of allowing them: with no
2245    /// classifier bound the gate escalates, so nothing lands outside the
2246    /// project unasked. Before this, an external write classified like an
2247    /// in-project edit and `auto` wrote it with a checkpoint and no question.
2248    #[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    /// The control: an in-project write in `auto` mode is still an ordinary
2276    /// edit and proceeds without a prompt.
2277    #[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}