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, 0.0),
168        };
169        if paths.is_empty() {
170            return ToolOutcome::error("read_file requires at least one path", 0.0);
171        }
172
173        let start = std::time::Instant::now();
174        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
175        let mut combined = String::new();
176        let mut any_truncated = false;
177
178        for (idx, raw_path) in paths.iter().enumerate() {
179            let target = match resolve_read_target(&roots, raw_path) {
180                Ok(target) => target,
181                Err(e) => {
182                    return ToolOutcome::error(
183                        format!("{raw_path}: {e}"),
184                        start.elapsed().as_secs_f64(),
185                    );
186                },
187            };
188            // 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                                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    #[expect(
523        clippy::too_many_lines,
524        reason = "the gated write path in order: parse, resolve in roots, policy gate, per-path \
525         lock, checkpoint, then the blocking write with its display diff; each step is a guard \
526         returning its own outcome, and the closing select needs everything the earlier steps \
527         produced (path, counts, plan flag, timer)"
528    )]
529    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
530        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
531            return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
532        };
533        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
534            return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
535        };
536
537        let start = std::time::Instant::now();
538        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
539        // `rel` is the root-relative name for the confined fd write (the actual
540        // byte path).
541        let ResolvedInRoot {
542            abs: abs_path,
543            rel,
544            root,
545            containment,
546        } = match resolve_in_roots(&roots, path) {
547            Ok(r) => r,
548            Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
549        };
550        let pending_action = serde_json::json!({
551            "tool": "write_file",
552            "args": { "path": path, "content": content },
553            "workdir": ctx.workdir.display().to_string(),
554            "turn_id": ctx.turn.0,
555            "call_id": ctx.call_id.0,
556            "task_id": ctx.task_id.clone(),
557        });
558        let plan_write = match mutation_policy_outcome(
559            &ctx,
560            "write_file",
561            path,
562            std::slice::from_ref(&abs_path),
563            pending_action,
564            containment,
565        )
566        .await
567        {
568            MutationGate::Blocked(outcome) => return *outcome,
569            MutationGate::Proceed { plan_write } => plan_write,
570        };
571        // Serialize writers to this canonical path: two write_file/edit calls to
572        // the same file in one turn run concurrently, so without this the last
573        // atomic rename silently wins (lost update). Distinct paths still overlap.
574        // The owned guard is Send and held across the spawn_blocking below.
575        let _write_guard = tokio::select! {
576            biased;
577            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
578            g = super::path_lock::lock_path(&abs_path) => g,
579        };
580        // Scratchpad files are session-private and ephemeral — never checkpointed.
581        if ctx.config.safety.checkpoint_on_mutation
582            && containment != PathContainment::Scratchpad
583            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
584                &ctx.workdir,
585                std::slice::from_ref(&abs_path),
586                Some(serde_json::json!({
587                    "tool": "write_file",
588                    "path": path,
589                })),
590                ctx.checkpoint_origin(),
591            )
592        {
593            return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
594        }
595        let display_path = path.to_string();
596        let line_count = content.lines().count();
597        let byte_count = content.len();
598        let content = content.to_string();
599
600        tokio::select! {
601            biased;
602            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
603            // The prior-content read (for the display diff) now happens INSIDE
604            // this blocking job and BOUNDED (#F44/RC-L) — never a synchronous
605            // unbounded `read_to_string` on the async worker thread.
606            result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
607                match result {
608                    Ok(Ok(write)) => {
609                        let duration_secs = start.elapsed().as_secs_f64();
610                        after_file_mutation(&ctx, "write_file", &display_path);
611                        ToolOutcome::success(
612                            format!("Wrote {} ({} lines)", display_path, write.line_count),
613                            format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
614                            duration_secs,
615                        )
616                        .with_metadata(ToolRunMetadata {
617                            detail: ToolMetadata::WriteFile {
618                                path: display_path,
619                                line_count,
620                                byte_count,
621                                created: Some(write.created),
622                            },
623                            line_count: Some(line_count),
624                            byte_count: Some(byte_count),
625                            display_diff: Some(write.diff.display_diff),
626                            diff_truncated: write.diff.truncated,
627                            lines_added: write.diff.added,
628                            lines_removed: write.diff.removed,
629                            plan_file_written: plan_write,
630                            ..ToolRunMetadata::default()
631                        })
632                    },
633                    Ok(Err(e)) => ToolOutcome::error(
634                        format!("write_file({display_path}): {e}"),
635                        start.elapsed().as_secs_f64(),
636                    ),
637                    Err(e) => ToolOutcome::error(
638                        format!("write_file join error: {e}"),
639                        start.elapsed().as_secs_f64(),
640                    ),
641                }
642            }
643        }
644    }
645}
646
647/// `edit_file` — precise search-and-replace editing on a single file.
648pub struct EditFileTool;
649
650#[async_trait]
651impl ToolExecutor for EditFileTool {
652    fn name(&self) -> &'static str {
653        "edit_file"
654    }
655
656    fn schema(&self) -> ToolDefinition {
657        defn(
658            "edit_file",
659            "Perform a precise search-and-replace edit on an existing file. Replaces `target_content` with `replacement_content`. Fails if `target_content` is not found or matches multiple locations (unless `allow_multiple` is true). Matching tolerates minor whitespace and quotation drift. Paths may be relative to the project directory or absolute.",
660            serde_json::json!({
661                "type": "object",
662                "properties": {
663                    "path": {
664                        "type": "string",
665                        "description": "Path to the file to edit."
666                    },
667                    "target_content": {
668                        "type": "string",
669                        "description": "The exact or near-exact block of text to replace. Must match uniquely in the file unless allow_multiple is true."
670                    },
671                    "replacement_content": {
672                        "type": "string",
673                        "description": "The new replacement text."
674                    },
675                    "allow_multiple": {
676                        "type": "boolean",
677                        "description": "If true, replace all occurrences of target_content instead of requiring uniqueness. Defaults to false."
678                    }
679                },
680                "required": ["path", "target_content", "replacement_content"]
681            }),
682        )
683    }
684
685    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
686        let start = std::time::Instant::now();
687        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
688            return ToolOutcome::error("edit_file requires 'path' (string)", 0.0);
689        };
690        let Some(target) = args.get("target_content").and_then(|v| v.as_str()) else {
691            return ToolOutcome::error("edit_file requires 'target_content' (string)", 0.0);
692        };
693        let Some(replacement) = args.get("replacement_content").and_then(|v| v.as_str()) else {
694            return ToolOutcome::error("edit_file requires 'replacement_content' (string)", 0.0);
695        };
696        let allow_multiple = args
697            .get("allow_multiple")
698            .and_then(serde_json::Value::as_bool)
699            .unwrap_or(false);
700
701        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
702        let ResolvedInRoot {
703            abs: abs_path,
704            rel,
705            root,
706            containment,
707        } = match resolve_in_roots(&roots, path) {
708            Ok(r) => r,
709            Err(e) => return ToolOutcome::error(format!("edit_file: {e}"), 0.0),
710        };
711
712        let pending_action = serde_json::json!({
713            "tool": "edit_file",
714            "args": {
715                "path": path,
716                "target_content": target,
717                "replacement_content": replacement,
718                "allow_multiple": allow_multiple,
719            },
720            "workdir": ctx.workdir.display().to_string(),
721            "turn_id": ctx.turn.0,
722            "call_id": ctx.call_id.0,
723            "task_id": ctx.task_id.clone(),
724        });
725        let plan_write = match mutation_policy_outcome(
726            &ctx,
727            "edit_file",
728            path,
729            std::slice::from_ref(&abs_path),
730            pending_action,
731            containment,
732        )
733        .await
734        {
735            MutationGate::Blocked(outcome) => return *outcome,
736            MutationGate::Proceed { plan_write } => plan_write,
737        };
738
739        let _write_guard = tokio::select! {
740            biased;
741            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
742            g = super::path_lock::lock_path(&abs_path) => g,
743        };
744
745        if ctx.config.safety.checkpoint_on_mutation
746            && containment != PathContainment::Scratchpad
747            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
748                &ctx.workdir,
749                std::slice::from_ref(&abs_path),
750                Some(serde_json::json!({
751                    "tool": "edit_file",
752                    "path": path,
753                })),
754                ctx.checkpoint_origin(),
755            )
756        {
757            return ToolOutcome::error(format!("edit_file checkpoint failed: {e}"), 0.0);
758        }
759
760        let display_path = path.to_string();
761        let target = target.to_string();
762        let replacement = replacement.to_string();
763
764        tokio::select! {
765            biased;
766            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
767            result = tokio::task::spawn_blocking(move || edit_file_blocking(&root, &rel, &target, &replacement, allow_multiple)) => {
768                match result {
769                    Ok(Ok(edit)) => {
770                        let duration_secs = start.elapsed().as_secs_f64();
771                        after_file_mutation(&ctx, "edit_file", &display_path);
772                        edit_success_outcome(&display_path, edit, plan_write, duration_secs)
773                    },
774                    Ok(Err(e)) => ToolOutcome::error(
775                        format!("edit_file({display_path}): {e}"),
776                        start.elapsed().as_secs_f64(),
777                    ),
778                    Err(e) => ToolOutcome::error(
779                        format!("edit_file join error: {e}"),
780                        start.elapsed().as_secs_f64(),
781                    ),
782                }
783            }
784        }
785    }
786}
787
788fn edit_success_outcome(
789    display_path: &str,
790    edit: EditResult,
791    plan_write: bool,
792    duration_secs: f64,
793) -> ToolOutcome {
794    let fuzzy_note = if edit.fuzzy {
795        "\nnote: matched with fuzzy (whitespace/Unicode) context; verify the result."
796    } else {
797        ""
798    };
799    ToolOutcome::success(
800        format!("Edited {display_path}{fuzzy_note}"),
801        diff_summary(edit.diff.added, edit.diff.removed, duration_secs),
802        duration_secs,
803    )
804    .with_metadata(ToolRunMetadata {
805        detail: ToolMetadata::ApplyPatch {
806            added: Vec::new(),
807            modified: vec![display_path.to_string()],
808            deleted: Vec::new(),
809            renamed: Vec::new(),
810            fuzzy: edit.fuzzy,
811        },
812        display_diff: Some(edit.diff.display_diff),
813        diff_truncated: edit.diff.truncated,
814        lines_added: edit.diff.added,
815        lines_removed: edit.diff.removed,
816        plan_file_written: plan_write,
817        ..ToolRunMetadata::default()
818    })
819}
820
821// ─── helpers ────────────────────────────────────────────────────────
822
823fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
824    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
825    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
826        reject_web_url(p)?;
827        return Ok(vec![p.to_string()]);
828    }
829    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
830        if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
831            return Err(format!(
832                "read_file: too many paths ({}); cap is {} per call — split the request",
833                arr.len(),
834                mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
835            ));
836        }
837        let mut out = Vec::with_capacity(arr.len());
838        for v in arr {
839            let Some(s) = v.as_str() else {
840                return Err("read_file 'paths' must be an array of strings".to_string());
841            };
842            reject_web_url(s)?;
843            out.push(s.to_string());
844        }
845        return Ok(out);
846    }
847    Err("read_file requires 'path' or 'paths'".to_string())
848}
849
850/// `read_file` reads the local filesystem, but models under a web-gated
851/// safety mode were observed pointing it at `https://` URLs and treating the
852/// result as a fetch — and the path-resolution error that came back said
853/// nothing about the actual mistake. Name the mistake and the right tool;
854/// whether `web_fetch` is available is then that tool's own story to tell.
855fn reject_web_url(path: &str) -> Result<(), String> {
856    let head: String = path
857        .trim_start()
858        .chars()
859        .take(8)
860        .collect::<String>()
861        .to_ascii_lowercase();
862    if head.starts_with("http://") || head.starts_with("https://") {
863        return Err(format!(
864            "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
865        ));
866    }
867    Ok(())
868}
869
870/// Read-only carve-out for memory facts. Global and project-private memory
871/// live under the OS data dir — outside both allowed roots — and the memory
872/// index tells the model to `read_file` the fact's path, so reads resolve
873/// against the memory roots too. Absolute paths only, with the same
874/// canonical (symlink-resolving) containment as the scratchpad arm. The
875/// write tools never consult this: memory mutation goes through the `memory`
876/// tool, where the policy gate can see it.
877fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
878    if !Path::new(raw).is_absolute() {
879        return None;
880    }
881    for (root, _scope) in crate::app::memory::memory_roots(workdir) {
882        if let Ok((_abs, true)) = resolve_path_within(&root, raw)
883            && let Ok(rel) = relative_within(&root, raw)
884        {
885            return Some((root, rel));
886        }
887    }
888    None
889}
890
891/// Where a `read_file` target lands once the resolver and the memory-root
892/// fallback have spoken.
893struct ReadTarget {
894    root: PathBuf,
895    rel: PathBuf,
896    /// `Some(abs)` when the target sits outside every allowed root and must
897    /// clear [`external_read_gate`] before it is opened.
898    external: Option<PathBuf>,
899}
900
901/// Resolve a read target through the canonical containment resolver, and
902/// answer its three-way verdict: project and scratchpad reads are ungated;
903/// durable-memory reads are ungated too (memory is agent-owned by design and
904/// lives outside the project); anything else outside the roots is external and
905/// carries its absolute path for the gate.
906///
907/// `Err` only when the path cannot be resolved at all (a workdir that will not
908/// canonicalize, or no existing ancestor) and is not a memory path either.
909fn resolve_read_target(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<ReadTarget> {
910    match resolve_in_roots(roots, raw) {
911        Ok(ResolvedInRoot {
912            abs,
913            rel,
914            root,
915            containment,
916        }) => match containment {
917            PathContainment::Project | PathContainment::Scratchpad => Ok(ReadTarget {
918                root,
919                rel,
920                external: None,
921            }),
922            PathContainment::External => Ok(resolve_in_memory_roots(roots.workdir, raw).map_or(
923                ReadTarget {
924                    root,
925                    rel,
926                    external: Some(abs),
927                },
928                |(root, rel)| ReadTarget {
929                    root,
930                    rel,
931                    external: None,
932                },
933            )),
934        },
935        Err(msg) => {
936            let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
937                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
938            Ok(ReadTarget {
939                root,
940                rel,
941                external: None,
942            })
943        },
944    }
945}
946
947/// Gate a read of `abs`, a path outside the project and the scratchpad.
948///
949/// Filed as `ToolCategory::ExternalDirectory` — an external side effect, the
950/// same class an out-of-project `working_dir` gets from the exec tool — so
951/// `ask` prompts (allowlistable per directory), `auto` consults the intent
952/// classifier, `full_access` proceeds, and the read-only floor (read-only and
953/// plan modes) denies. Not replayable: a read has nothing to replay, so a
954/// headless `ask` session refuses it unless untrusted tools were allowed.
955///
956/// Returns the blocking outcome, or `None` to proceed.
957async fn external_read_gate(ctx: &ExecContext, raw: &str, abs: &Path) -> Option<ToolOutcome> {
958    let mut request = mermaid_runtime::ActionRequest::new(
959        "read_file",
960        mermaid_runtime::ToolCategory::ExternalDirectory,
961        format!("read_file {raw}"),
962    );
963    request.path = Some(abs.display().to_string());
964    let pending_action = serde_json::json!({
965        "tool": "read_file",
966        "args": { "path": raw },
967        "workdir": ctx.workdir.display().to_string(),
968        "turn_id": ctx.turn.0,
969        "call_id": ctx.call_id.0,
970        "task_id": ctx.task_id.clone(),
971    });
972    match super::policy_gate::gate(ctx, request, &[], pending_action, false, false).await {
973        super::policy_gate::Gate::Block(outcome) => Some(outcome),
974        super::policy_gate::Gate::Proceed { .. } => None,
975    }
976}
977
978/// Read one file (bounded) from `rel` beneath `root`. Returns the (possibly
979/// marker-footed) text and the REAL truncation flag from the bounded read, so
980/// the caller propagates that rather than sniffing the output for the marker
981/// string — which a file whose own content contains that literal text would
982/// otherwise falsely trip (#78).
983///
984/// The root-relative path feeds the confined fd read, so the bytes come from
985/// the inode the kernel resolved under `RESOLVE_BENEATH` rather than whatever
986/// a concurrently-swapped symlink now points at (#77).
987async fn read_one(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool)> {
988    let result = tokio::task::spawn_blocking(move || {
989        let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
990        // Bounded read: never pull more than the cap (+1 probe byte) into RAM,
991        // so a model pointing `read_file` at a multi-gigabyte file can't OOM the
992        // process — a full read would have slurped the whole thing first (#15).
993        let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
994        let mut s = String::from_utf8_lossy(&data).into_owned();
995        if truncated {
996            // Char-boundary-safe truncation with a marker footer.
997            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
998            s.truncate(cut);
999            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
1000        }
1001        Ok::<_, std::io::Error>((s, truncated))
1002    })
1003    .await
1004    .map_err(|e| std::io::Error::other(e.to_string()))??;
1005    Ok(result)
1006}
1007
1008/// Write `content` to `rel` beneath `root` (the project workdir or the session
1009/// scratchpad) through the symlink-confined *atomic* writer, creating parent
1010/// dirs the same confined way. The bytes are written to a temp and
1011/// `renameat`-swapped over the target, all beneath the directory fd the kernel
1012/// resolved under `RESOLVE_BENEATH`: a parent dir swapped for an escaping
1013/// symlink can't redirect the write (#77), and a crash/kill/disk-full
1014/// mid-write leaves the previous file intact rather than a truncated or
1015/// half-written one.
1016fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
1017    if let Some(parent) = rel.parent()
1018        && !parent.as_os_str().is_empty()
1019    {
1020        mermaid_runtime::create_dir_all_beneath(root, parent)?;
1021    }
1022    mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
1023    Ok(content.lines().count())
1024}
1025
1026struct WriteResult {
1027    line_count: usize,
1028    created: bool,
1029    diff: mermaid_model::diff::DisplayDiff,
1030}
1031
1032/// Write `content` and build the display diff against the prior file in ONE
1033/// blocking job (#F44/RC-L). The prior content is read BOUNDED via
1034/// [`mermaid_model::utils::read_file_capped`] — overwriting a multi-gigabyte file must
1035/// not slurp it into RAM on the async worker just to render a diff. A prior file
1036/// larger than the read cap (or otherwise unreadable) is elided from the diff
1037/// rather than read whole.
1038fn write_with_diff_blocking(
1039    root: &Path,
1040    abs_path: &Path,
1041    rel: &Path,
1042    content: &str,
1043) -> std::io::Result<WriteResult> {
1044    let (old_content, created, elide_diff) =
1045        match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
1046            Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
1047            // Existing file is past the read cap — don't pull it all into RAM.
1048            Ok((_, true)) => (String::new(), false, true),
1049            // Missing file → a fresh create; the diff shows the whole content added.
1050            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1051            // Any other read error: don't fail the write over a diff preview.
1052            Err(_) => (String::new(), false, true),
1053        };
1054    let diff = if elide_diff {
1055        mermaid_model::diff::DisplayDiff {
1056            display_diff: format!(
1057                "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
1058            ),
1059            added: 0,
1060            removed: 0,
1061            truncated: true,
1062        }
1063    } else {
1064        mermaid_model::diff::generate_display_diff(&old_content, content)
1065    };
1066    let line_count = write_one_blocking(root, rel, content)?;
1067    Ok(WriteResult {
1068        line_count,
1069        created,
1070        diff,
1071    })
1072}
1073
1074struct EditResult {
1075    diff: mermaid_model::diff::DisplayDiff,
1076    fuzzy: bool,
1077}
1078
1079fn edit_file_blocking(
1080    root: &Path,
1081    rel: &Path,
1082    target: &str,
1083    replacement: &str,
1084    allow_multiple: bool,
1085) -> Result<EditResult, String> {
1086    let file = mermaid_runtime::open_beneath(root, rel, mermaid_runtime::OpenIntent::Read)
1087        .map_err(|e| format!("cannot open file: {e}"))?;
1088    let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)
1089        .map_err(|e| format!("cannot read file: {e}"))?;
1090    if truncated {
1091        return Err(format!(
1092            "file exceeds maximum size limit ({MAX_FILE_READ_BYTES} bytes)"
1093        ));
1094    }
1095    let original = String::from_utf8_lossy(&data).into_owned();
1096    let applied = mermaid_runtime::replace_content(&original, target, replacement, allow_multiple)
1097        .map_err(|e| e.to_string())?;
1098
1099    write_one_blocking(root, rel, &applied.new_contents)
1100        .map_err(|e| format!("cannot write file: {e}"))?;
1101
1102    let diff = mermaid_model::diff::generate_display_diff(&original, &applied.new_contents);
1103    Ok(EditResult {
1104        diff,
1105        fuzzy: applied.fuzzy,
1106    })
1107}
1108
1109/// Outcome of gating a file mutation. `Proceed` carries `plan_write`: whether
1110/// the allowance came from plan mode's plan-file carve-out, which the caller
1111/// stamps onto `ToolRunMetadata::plan_file_written`.
1112///
1113/// The tool NAME is not a usable stand-in for this. "Under the plan floor the
1114/// only Edit that can succeed is the plan file" stops being true as soon as
1115/// `[plan] memory = allow` lets a `write_file` to a memory path succeed.
1116pub(super) enum MutationGate {
1117    /// Blocked — return this outcome verbatim. Boxed to keep the enum small.
1118    Blocked(Box<ToolOutcome>),
1119    Proceed {
1120        plan_write: bool,
1121    },
1122}
1123
1124/// Gate a file mutation by where its target landed.
1125///
1126/// `containment` is the resolver's verdict for the path, matched here so that
1127/// every mutating tool answers the three-way question in one place:
1128/// - `Project`: an ordinary edit (`ToolCategory::Edit`).
1129/// - `Scratchpad`: the gate downgrades an `Ask`/`Classify` to proceed — scratch
1130///   files are session-private and ephemeral — while read-only mode and `Deny`
1131///   overrides still block it.
1132/// - `External`: a path outside both roots is an external side effect
1133///   (`ToolCategory::ExternalDirectory`), so it prompts in `ask`, goes through
1134///   the intent classifier in `auto`, and is denied by the read-only floor —
1135///   the same treatment an out-of-project `working_dir` gets from the exec
1136///   tool. Before this, an external write classified like an in-project one
1137///   and `auto` mode allowed it unasked.
1138pub(super) async fn mutation_policy_outcome(
1139    ctx: &ExecContext,
1140    tool: &str,
1141    path: &str,
1142    checkpoint_paths: &[PathBuf],
1143    pending_action: serde_json::Value,
1144    containment: PathContainment,
1145) -> MutationGate {
1146    let category = match containment {
1147        PathContainment::Project | PathContainment::Scratchpad => {
1148            mermaid_runtime::ToolCategory::Edit
1149        },
1150        PathContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
1151    };
1152    let mut request = mermaid_runtime::ActionRequest::new(tool, category, format!("{tool} {path}"));
1153    request.path = Some(path.to_string());
1154    // File mutations are replayable: an Ask decision checkpoints, records an
1155    // approval, and blocks (handled inside the gate).
1156    match super::policy_gate::gate(
1157        ctx,
1158        request,
1159        checkpoint_paths,
1160        pending_action,
1161        true,
1162        containment == PathContainment::Scratchpad,
1163    )
1164    .await
1165    {
1166        super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
1167        super::policy_gate::Gate::Proceed { plan_write, .. } => {
1168            let _ = mermaid_runtime::run_plugin_hooks(
1169                "before_file_mutation",
1170                &serde_json::json!({
1171                    "task_id": ctx.task_id.clone(),
1172                    "turn_id": ctx.turn.0,
1173                    "call_id": ctx.call_id.0,
1174                    "tool": tool,
1175                    "path": path,
1176                }),
1177            );
1178            MutationGate::Proceed { plan_write }
1179        },
1180    }
1181}
1182
1183pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
1184    let _ = mermaid_runtime::run_plugin_hooks(
1185        "after_file_mutation",
1186        &serde_json::json!({
1187            "task_id": ctx.task_id.clone(),
1188            "turn_id": ctx.turn.0,
1189            "call_id": ctx.call_id.0,
1190            "tool": tool,
1191            "path": path,
1192        }),
1193    );
1194}
1195
1196fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
1197    ToolOutcome::error(msg, duration_secs)
1198}
1199
1200fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
1201    if count == 1 { singular } else { plural }
1202}
1203
1204pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
1205    format!(
1206        "+{} -{}, took {}",
1207        added,
1208        removed,
1209        format_duration_for_diff(duration_secs)
1210    )
1211}
1212
1213fn format_duration_for_diff(seconds: f64) -> String {
1214    if seconds < 1.0 {
1215        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1216    } else if seconds < 10.0 {
1217        format!("{seconds:.1}s")
1218    } else {
1219        format!("{}s", seconds.round() as u64)
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226    use crate::providers::ctx::test_exec_context;
1227    use mermaid_domain::{ToolCallId, TurnId};
1228    use std::fs;
1229
1230    /// Memory facts live outside the project/scratchpad roots and the index
1231    /// tells the model to `read_file` them — reads must resolve against the
1232    /// memory roots (here the `ProjectShared` root, reached by putting the
1233    /// workdir in a subdir of the git root), while unrelated outside paths
1234    /// stay rejected.
1235    #[tokio::test]
1236    async fn read_file_resolves_memory_roots_read_only() {
1237        let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
1238        let _ = fs::remove_dir_all(&base);
1239        let repo = base.join("repo");
1240        let workdir = repo.join("src");
1241        fs::create_dir_all(&workdir).unwrap();
1242        fs::create_dir_all(repo.join(".git")).unwrap();
1243        let mem_dir = repo.join(".mermaid").join("memory");
1244        fs::create_dir_all(&mem_dir).unwrap();
1245        let fact = mem_dir.join("fact.md");
1246        fs::write(&fact, "the fact body").unwrap();
1247
1248        let roots = AllowedRoots::new(&workdir, None);
1249        // A memory path sits outside the project yet resolves ungated: memory
1250        // is agent-owned by design.
1251        let target = resolve_read_target(&roots, fact.to_str().unwrap()).unwrap();
1252        assert!(target.external.is_none(), "memory reads are not external");
1253        let (content, truncated) = read_one(target.root, target.rel).await.unwrap();
1254        assert!(!truncated);
1255        assert_eq!(content, "the fact body");
1256
1257        // A stray path outside every root resolves too, but carries its
1258        // absolute path for the gate: `execute` must not read it unasked.
1259        let stray = base.join("stray.txt");
1260        fs::write(&stray, "nope").unwrap();
1261        let target = resolve_read_target(&roots, stray.to_str().unwrap()).unwrap();
1262        assert_eq!(
1263            target.external.as_deref().map(|p| p.ends_with("stray.txt")),
1264            Some(true),
1265            "a path outside every root is external"
1266        );
1267
1268        let _ = fs::remove_dir_all(&base);
1269    }
1270
1271    #[test]
1272    fn resolve_in_roots_contains_to_workdir() {
1273        let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
1274        let _ = fs::remove_dir_all(&root);
1275        fs::create_dir_all(root.join("sub")).unwrap();
1276        let roots = AllowedRoots::new(&root, None);
1277
1278        // In-root existing + not-yet-existing targets resolve inside root.
1279        assert!(resolve_in_roots(&roots, "sub").is_ok());
1280        let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
1281        let canon_root = fs::canonicalize(&root).unwrap();
1282        assert!(resolved.abs.starts_with(&canon_root));
1283
1284        // External paths and relative .. escapes resolve successfully.
1285        assert!(resolve_in_roots(&roots, "../escape.txt").is_ok());
1286        let outside = std::env::temp_dir().join("definitely_outside.txt");
1287        let r = resolve_in_roots(&roots, &outside.display().to_string()).unwrap();
1288        assert!(r.abs.ends_with(Path::new("definitely_outside.txt")));
1289
1290        let _ = fs::remove_dir_all(&root);
1291    }
1292
1293    fn temp_root(name: &str) -> PathBuf {
1294        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
1295        let _ = fs::remove_dir_all(&p);
1296        fs::create_dir_all(&p).expect("create tmpdir");
1297        p
1298    }
1299
1300    #[tokio::test]
1301    async fn read_file_returns_content() {
1302        let dir = temp_root("read_ok");
1303        fs::write(dir.join("a.txt"), "hello").expect("write");
1304        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1305
1306        let tool = ReadFileTool;
1307        let outcome = tool
1308            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1309            .await;
1310        assert!(outcome.is_success(), "expected success: {outcome:?}");
1311        assert_eq!(outcome.output(), "hello");
1312        let _ = fs::remove_dir_all(&dir);
1313    }
1314
1315    #[tokio::test]
1316    async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
1317        // Observed in the field: a model under a web-gated safety mode fed
1318        // `read_file` an https:// URL and treated the reply as a fetch. The
1319        // rejection must name the right tool — and a plain local read next to
1320        // it must keep working (the guard cannot overmatch).
1321        let dir = temp_root("read_url");
1322        fs::write(dir.join("a.txt"), "hello").expect("write");
1323        for args in [
1324            serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1325            serde_json::json!({"path": "HTTP://example.com/x"}),
1326            serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1327        ] {
1328            let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1329            let outcome = ReadFileTool.execute(args, ctx).await;
1330            assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1331            let msg = outcome.error_message().unwrap_or_default();
1332            assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1333        }
1334        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1335        let outcome = ReadFileTool
1336            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1337            .await;
1338        assert!(outcome.is_success(), "plain local reads must still work");
1339        let _ = fs::remove_dir_all(&dir);
1340    }
1341
1342    #[tokio::test]
1343    async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1344        let dir = temp_root("read_dedup");
1345        fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1346
1347        // First read: full content.
1348        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1349        let outcome = ReadFileTool
1350            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1351            .await;
1352        assert_eq!(outcome.output(), "line one\nline two");
1353
1354        // Byte-identical repeat in the SAME turn: a short reuse note, not
1355        // the body again — the earlier result rides the same request.
1356        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1357        let outcome = ReadFileTool
1358            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1359            .await;
1360        assert!(outcome.is_success());
1361        assert!(
1362            outcome
1363                .output()
1364                .contains("unchanged since your read earlier this turn"),
1365            "{}",
1366            outcome.output()
1367        );
1368        assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1369        assert!(
1370            !outcome.output().contains("line two"),
1371            "the body must not repeat: {}",
1372            outcome.output()
1373        );
1374
1375        // Matched pair (a): the file CHANGED on disk — by any writer; here a
1376        // direct fs write stands in for write_file / apply_patch /
1377        // execute_command — so the same-turn re-read returns full content.
1378        // Content-hash equality is the invalidation; there is no hook to
1379        // forget.
1380        fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1381        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1382        let outcome = ReadFileTool
1383            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1384            .await;
1385        assert_eq!(
1386            outcome.output(),
1387            "line one\nline two\nline three",
1388            "a changed file must read in full"
1389        );
1390
1391        // ...and the byte-identical repeat of THAT read collapses again.
1392        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1393        let outcome = ReadFileTool
1394            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1395            .await;
1396        assert!(
1397            outcome.output().contains("unchanged since"),
1398            "{}",
1399            outcome.output()
1400        );
1401
1402        // Matched pair (b): a LATER turn always reads in full — a cross-turn
1403        // re-read may be a legitimate refresh (post-compaction, post-edit)
1404        // and is never suppressed.
1405        let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1406        let outcome = ReadFileTool
1407            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1408            .await;
1409        assert_eq!(outcome.output(), "line one\nline two\nline three");
1410
1411        let _ = fs::remove_dir_all(&dir);
1412    }
1413
1414    #[tokio::test]
1415    async fn read_file_missing_path_errors() {
1416        let dir = temp_root("read_missing_path");
1417        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1418        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1419        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1420        let _ = fs::remove_dir_all(&dir);
1421    }
1422
1423    #[tokio::test]
1424    async fn read_file_nonexistent_errors() {
1425        let dir = temp_root("read_nonex");
1426        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1427        let outcome = ReadFileTool
1428            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1429            .await;
1430        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1431        let _ = fs::remove_dir_all(&dir);
1432    }
1433
1434    #[tokio::test]
1435    async fn read_file_with_multiple_paths_joins_contents() {
1436        let dir = temp_root("read_multi");
1437        fs::write(dir.join("a.txt"), "alpha").expect("write");
1438        fs::write(dir.join("b.txt"), "beta").expect("write");
1439        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1440        let outcome = ReadFileTool
1441            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1442            .await;
1443        assert!(outcome.is_success(), "expected success: {outcome:?}");
1444        let output = outcome.output();
1445        assert!(output.contains("=== a.txt ==="));
1446        assert!(output.contains("alpha"));
1447        assert!(output.contains("=== b.txt ==="));
1448        assert!(output.contains("beta"));
1449        let _ = fs::remove_dir_all(&dir);
1450    }
1451
1452    #[tokio::test]
1453    async fn read_file_multi_aggregate_is_capped() {
1454        // F45: many files in one call can't blow past the aggregate cap. Each
1455        // file is under the per-file cap, but their sum exceeds the aggregate.
1456        let dir = temp_root("read_aggregate_cap");
1457        let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1458        fs::write(dir.join("a.txt"), &chunk).expect("write a");
1459        fs::write(dir.join("b.txt"), &chunk).expect("write b");
1460        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1461        let outcome = ReadFileTool
1462            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1463            .await;
1464        assert!(outcome.is_success(), "expected success: {outcome:?}");
1465        let output = outcome.output();
1466        assert!(
1467            output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1468            "combined must be capped, got {} bytes",
1469            output.len()
1470        );
1471        assert!(
1472            output.contains("elided"),
1473            "expected aggregate head+tail elision marker"
1474        );
1475        match &outcome.metadata.detail {
1476            ToolMetadata::ReadFile { truncated, .. } => {
1477                assert!(*truncated, "aggregate truncation must set truncated")
1478            },
1479            other => panic!("expected ReadFile metadata, got {other:?}"),
1480        }
1481        let _ = fs::remove_dir_all(&dir);
1482    }
1483
1484    #[tokio::test]
1485    async fn write_file_elides_diff_for_oversized_existing_file() {
1486        // F44: overwriting a file larger than the read cap must NOT slurp it into
1487        // RAM for a diff — the diff is elided with a marker instead.
1488        let dir = temp_root("write_oversized_diff");
1489        let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1490        fs::write(dir.join("big.txt"), &big).expect("write fixture");
1491        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1492        let outcome = WriteFileTool
1493            .execute(
1494                serde_json::json!({"path": "big.txt", "content": "small\n"}),
1495                ctx,
1496            )
1497            .await;
1498        assert!(outcome.is_success(), "expected success: {outcome:?}");
1499        let diff = outcome
1500            .metadata
1501            .display_diff
1502            .as_deref()
1503            .expect("display diff");
1504        assert!(
1505            diff.contains("diff preview skipped"),
1506            "expected elision marker, got: {diff}"
1507        );
1508        assert!(
1509            outcome.metadata.diff_truncated,
1510            "oversized diff must set diff_truncated"
1511        );
1512        match &outcome.metadata.detail {
1513            ToolMetadata::WriteFile { created, .. } => {
1514                assert_eq!(*created, Some(false), "existing file is not 'created'")
1515            },
1516            other => panic!("expected WriteFile metadata, got {other:?}"),
1517        }
1518        // The file was actually overwritten despite the elided diff.
1519        let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1520        assert_eq!(written, "small\n");
1521        let _ = fs::remove_dir_all(&dir);
1522    }
1523
1524    #[tokio::test]
1525    async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1526        // #78: a small file whose own content contains the truncation-marker
1527        // string must NOT be reported as truncated — the flag comes from the
1528        // bounded read now, not a substring sniff of the output.
1529        let dir = temp_root("read_marker_content");
1530        fs::write(
1531            dir.join("a.txt"),
1532            "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1533        )
1534        .expect("write");
1535        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1536
1537        let outcome = ReadFileTool
1538            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1539            .await;
1540        assert!(outcome.is_success(), "expected success: {outcome:?}");
1541        match &outcome.metadata.detail {
1542            ToolMetadata::ReadFile { truncated, .. } => assert!(
1543                !truncated,
1544                "a file whose content contains the marker must not be flagged truncated"
1545            ),
1546            other => panic!("expected ReadFile metadata, got {other:?}"),
1547        }
1548        let _ = fs::remove_dir_all(&dir);
1549    }
1550
1551    #[tokio::test]
1552    async fn read_file_respects_cancellation() {
1553        let dir = temp_root("read_cancel");
1554        // Write a huge file so the read is slow enough to race cancel.
1555        // Actually spawn_blocking on read is fast on tmpfs — this test
1556        // just verifies the select! arm compiles + the token trips
1557        // the cancel path when pre-cancelled.
1558        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1559        ctx.token.cancel();
1560        let outcome = ReadFileTool
1561            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1562            .await;
1563        assert!(outcome.was_cancelled());
1564        let _ = fs::remove_dir_all(&dir);
1565    }
1566
1567    #[tokio::test]
1568    async fn write_file_creates_and_counts_lines() {
1569        let dir = temp_root("write_ok");
1570        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1571        let outcome = WriteFileTool
1572            .execute(
1573                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1574                ctx,
1575            )
1576            .await;
1577        assert!(outcome.is_success(), "expected success: {outcome:?}");
1578        assert!(outcome.output().contains("3 lines"));
1579        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1580        assert!(written.contains("line1"));
1581        let _ = fs::remove_dir_all(&dir);
1582    }
1583
1584    #[tokio::test]
1585    async fn concurrent_write_file_same_path_serializes_cleanly() {
1586        // The per-path write gate must let two writes to the same file in one turn
1587        // both succeed and leave the file as exactly one clean write (never a
1588        // corrupt interleave), and must not deadlock.
1589        let dir = temp_root("write_race");
1590        let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1591        let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1592        let a = "AAAA\nAAAA\n";
1593        let b = "BBBB\nBBBB\n";
1594        let (o1, o2) = tokio::join!(
1595            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1596            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1597        );
1598        assert!(o1.is_success(), "first write failed: {o1:?}");
1599        assert!(o2.is_success(), "second write failed: {o2:?}");
1600        let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1601        assert!(
1602            final_content == a || final_content == b,
1603            "file must be exactly one clean write, got {final_content:?}"
1604        );
1605        let _ = fs::remove_dir_all(&dir);
1606    }
1607
1608    #[tokio::test]
1609    async fn write_file_new_file_records_added_display_diff() {
1610        let dir = temp_root("write_new_diff");
1611        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1612        let outcome = WriteFileTool
1613            .execute(
1614                serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1615                ctx,
1616            )
1617            .await;
1618        assert!(outcome.is_success(), "expected success: {outcome:?}");
1619        let diff = outcome
1620            .metadata
1621            .display_diff
1622            .as_deref()
1623            .expect("display diff");
1624        assert!(diff.contains("+ alpha"));
1625        assert!(diff.contains("+ beta"));
1626        // No unified-diff header clutter (`---`/`+++`/`@@`).
1627        assert!(
1628            !diff.contains("@@"),
1629            "diff should not carry hunk headers: {diff}"
1630        );
1631        assert!(!diff.contains("/dev/null"));
1632        let _ = fs::remove_dir_all(&dir);
1633    }
1634
1635    #[tokio::test]
1636    async fn write_file_existing_file_records_added_and_removed_display_diff() {
1637        let dir = temp_root("write_existing_diff");
1638        fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1639        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1640        let outcome = WriteFileTool
1641            .execute(
1642                serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1643                ctx,
1644            )
1645            .await;
1646        assert!(outcome.is_success(), "expected success: {outcome:?}");
1647        let diff = outcome
1648            .metadata
1649            .display_diff
1650            .as_deref()
1651            .expect("display diff");
1652        assert!(diff.contains("- old"));
1653        assert!(diff.contains("+ new"));
1654        let _ = fs::remove_dir_all(&dir);
1655    }
1656
1657    #[tokio::test]
1658    async fn write_file_creates_parent_dirs() {
1659        let dir = temp_root("write_parents");
1660        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1661        let outcome = WriteFileTool
1662            .execute(
1663                serde_json::json!({
1664                    "path": "sub/nested/out.txt",
1665                    "content": "deep",
1666                }),
1667                ctx,
1668            )
1669            .await;
1670        assert!(outcome.is_success(), "expected success: {outcome:?}");
1671        assert!(dir.join("sub/nested/out.txt").exists());
1672        let _ = fs::remove_dir_all(&dir);
1673    }
1674
1675    #[tokio::test]
1676    async fn write_file_missing_content_errors() {
1677        let dir = temp_root("write_missing");
1678        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1679        let outcome = WriteFileTool
1680            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1681            .await;
1682        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1683        let _ = fs::remove_dir_all(&dir);
1684    }
1685
1686    // ─── F10: absolute-path block ───────────────────────────────────
1687
1688    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1689    /// must fail with a clear "outside the project" error. The tool
1690    /// schema advertises this contract; before F10 it was a lie.
1691    /// Reading an absolute path outside workdir succeeds.
1692    #[tokio::test]
1693    async fn read_file_allows_absolute_path_outside_workdir() {
1694        let dir = temp_root("read_abs_escape");
1695        let external_dir = temp_root("read_abs_external");
1696        let external_file = external_dir.join("ext.txt");
1697        fs::write(&external_file, "external content").unwrap();
1698        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1699        let outcome = ReadFileTool
1700            .execute(
1701                serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
1702                ctx,
1703            )
1704            .await;
1705        assert!(outcome.is_success(), "expected success: {outcome:?}");
1706        assert_eq!(outcome.output(), "external content");
1707        let _ = fs::remove_dir_all(&dir);
1708        let _ = fs::remove_dir_all(&external_dir);
1709    }
1710
1711    /// Absolute path that lives INSIDE the workdir is allowed.
1712    #[tokio::test]
1713    async fn read_file_accepts_absolute_path_inside_workdir() {
1714        let dir = temp_root("read_abs_inside");
1715        let file = dir.join("hello.txt");
1716        fs::write(&file, "ok").expect("write fixture");
1717        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1718        let outcome = ReadFileTool
1719            .execute(
1720                serde_json::json!({"path": file.to_string_lossy().to_string()}),
1721                ctx,
1722            )
1723            .await;
1724        assert!(outcome.is_success(), "expected success: {outcome:?}");
1725        let _ = fs::remove_dir_all(&dir);
1726    }
1727
1728    /// Relative `..`-escape and external paths are supported.
1729    #[tokio::test]
1730    async fn write_file_allows_relative_parent_and_external_path() {
1731        let base = temp_root("write_dotdot_escape");
1732        let dir = base.join("project");
1733        fs::create_dir_all(&dir).unwrap();
1734        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1735        let outcome = WriteFileTool
1736            .execute(
1737                serde_json::json!({
1738                    "path": "../escape.txt",
1739                    "content": "written outside",
1740                }),
1741                ctx,
1742            )
1743            .await;
1744        assert!(
1745            outcome.is_success(),
1746            "expected success for write to ../escape.txt"
1747        );
1748        let written = base.join("escape.txt");
1749        assert!(written.exists());
1750        assert_eq!(fs::read_to_string(&written).unwrap(), "written outside");
1751        let _ = fs::remove_dir_all(&base);
1752    }
1753
1754    /// `create_directory` needs the lexical-normalization fallback
1755    /// because the target doesn't exist yet (can't canonicalize).
1756    /// Verify the escape check still fires for non-existent targets.
1757    /// `create_directory` creates directories outside workdir when called.
1758    #[tokio::test]
1759    async fn create_directory_allows_absolute_path_outside_workdir() {
1760        let dir = temp_root("mkdir_abs_proj");
1761        let target = std::env::temp_dir().join(format!("mermaid_fs_target_{}", std::process::id()));
1762        let _ = fs::remove_dir_all(&target);
1763        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1764        let outcome = CreateDirectoryTool
1765            .execute(
1766                serde_json::json!({"path": target.to_string_lossy().to_string()}),
1767                ctx,
1768            )
1769            .await;
1770        assert!(outcome.is_success(), "expected success: {outcome:?}");
1771        assert!(target.exists());
1772        let _ = fs::remove_dir_all(&target);
1773        let _ = fs::remove_dir_all(&dir);
1774    }
1775
1776    // ─── session-scratchpad dual root ────────────────────────────────
1777
1778    /// Build an `ExecContext` with an explicit safety mode, NO approval
1779    /// broker, and (optionally) a materialized scratchpad. Unlike
1780    /// `test_exec_context` (pinned to `FullAccess`) this exercises the gate.
1781    fn scratch_ctx(
1782        mode: mermaid_runtime::SafetyMode,
1783        workdir: PathBuf,
1784        scratchpad: Option<PathBuf>,
1785    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1786        let mut config = mermaid_domain::Config::default();
1787        config.safety.mode = mode;
1788        let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
1789            TurnId(1),
1790            ToolCallId(1),
1791            workdir,
1792            config,
1793        );
1794        ctx.scratchpad = scratchpad;
1795        (ctx, rx)
1796    }
1797
1798    /// Project + scratch fixture pair with a unique, greppable name.
1799    fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1800        let base = std::env::temp_dir().join(format!(
1801            "mermaid_fs_scratch_{}_{}",
1802            name,
1803            std::process::id()
1804        ));
1805        let _ = fs::remove_dir_all(&base);
1806        let project = base.join("project");
1807        let scratch = base.join("scratch");
1808        fs::create_dir_all(&project).unwrap();
1809        fs::create_dir_all(&scratch).unwrap();
1810        (project, scratch)
1811    }
1812
1813    /// True when any checkpoint manifest on disk references `marker`. The
1814    /// fixture paths are unique per test+pid, so a hit can only come from
1815    /// the mutation under test.
1816    fn any_checkpoint_mentions(marker: &str) -> bool {
1817        let Ok(data) = mermaid_runtime::data_dir() else {
1818            return false;
1819        };
1820        let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1821            return false;
1822        };
1823        entries.flatten().any(|entry| {
1824            fs::read_to_string(entry.path().join("manifest.json"))
1825                .is_ok_and(|manifest| manifest.contains(marker))
1826        })
1827    }
1828
1829    /// Scratchpad mutations proceed in Ask mode with NO approval broker
1830    /// bound (the gate is bypassed entirely) and never take a checkpoint.
1831    #[tokio::test]
1832    async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1833        let (project, scratch) = scratch_fixture("ungated");
1834        let marker = scratch.display().to_string();
1835
1836        // write_file into the scratchpad via absolute path.
1837        let file = scratch.join("notes.txt");
1838        let (ctx, _rx) = scratch_ctx(
1839            mermaid_runtime::SafetyMode::Ask,
1840            project.clone(),
1841            Some(scratch.clone()),
1842        );
1843        let outcome = WriteFileTool
1844            .execute(
1845                serde_json::json!({
1846                    "path": file.to_str().unwrap(),
1847                    "content": "scratch note\n",
1848                }),
1849                ctx,
1850            )
1851            .await;
1852        assert!(outcome.is_success(), "scratch write: {outcome:?}");
1853        assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1854
1855        // create_directory inside the scratchpad.
1856        let subdir = scratch.join("work/area");
1857        let (ctx, _rx) = scratch_ctx(
1858            mermaid_runtime::SafetyMode::Ask,
1859            project.clone(),
1860            Some(scratch.clone()),
1861        );
1862        let outcome = CreateDirectoryTool
1863            .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1864            .await;
1865        assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1866        assert!(subdir.is_dir());
1867
1868        // delete_file inside the scratchpad.
1869        let (ctx, _rx) = scratch_ctx(
1870            mermaid_runtime::SafetyMode::Ask,
1871            project.clone(),
1872            Some(scratch.clone()),
1873        );
1874        let outcome = DeleteFileTool
1875            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1876            .await;
1877        assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1878        assert!(!file.exists());
1879
1880        // None of the mutations checkpointed the ephemeral scratch paths.
1881        assert!(
1882            !any_checkpoint_mentions(&marker),
1883            "scratch mutation must not create a checkpoint"
1884        );
1885        let _ = fs::remove_dir_all(project.parent().unwrap());
1886    }
1887
1888    /// `ReadOnly` still blocks scratchpad mutations — the bypass only skips
1889    /// the approval flow, never the mode's mutation ban.
1890    #[tokio::test]
1891    async fn scratch_mutation_blocked_in_read_only() {
1892        let (project, scratch) = scratch_fixture("readonly");
1893        let file = scratch.join("blocked.txt");
1894        let (ctx, _rx) = scratch_ctx(
1895            mermaid_runtime::SafetyMode::ReadOnly,
1896            project.clone(),
1897            Some(scratch.clone()),
1898        );
1899        let outcome = WriteFileTool
1900            .execute(
1901                serde_json::json!({
1902                    "path": file.to_str().unwrap(),
1903                    "content": "nope",
1904                }),
1905                ctx,
1906            )
1907            .await;
1908        let error = outcome.error_message().expect("expected block");
1909        assert!(
1910            error.contains("blocked by policy"),
1911            "expected policy block, got: {error}"
1912        );
1913        assert!(!file.exists());
1914        let _ = fs::remove_dir_all(project.parent().unwrap());
1915    }
1916
1917    /// A path outside scratchpad in Ask mode requires approval.
1918    #[tokio::test]
1919    async fn write_outside_both_roots_requires_approval_in_ask_mode() {
1920        let (project, scratch) = scratch_fixture("outside");
1921        let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
1922        let (ctx, _rx) = scratch_ctx(
1923            mermaid_runtime::SafetyMode::Ask,
1924            project.clone(),
1925            Some(scratch.clone()),
1926        );
1927        let outcome = WriteFileTool
1928            .execute(
1929                serde_json::json!({
1930                    "path": outside.to_str().unwrap(),
1931                    "content": "should require approval",
1932                }),
1933                ctx,
1934            )
1935            .await;
1936        let error = outcome.error_message().expect("expected approval required");
1937        assert!(
1938            error.contains("Approval required"),
1939            "expected approval block, got: {error}"
1940        );
1941        assert!(!outside.exists());
1942        let _ = fs::remove_dir_all(project.parent().unwrap());
1943    }
1944
1945    /// `read_file` follows a materialized scratchpad too.
1946    #[tokio::test]
1947    async fn read_file_reads_from_scratchpad() {
1948        let (project, scratch) = scratch_fixture("read");
1949        let file = scratch.join("stash.txt");
1950        fs::write(&file, "stashed").unwrap();
1951        let (ctx, _rx) = scratch_ctx(
1952            mermaid_runtime::SafetyMode::Ask,
1953            project.clone(),
1954            Some(scratch.clone()),
1955        );
1956        let outcome = ReadFileTool
1957            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1958            .await;
1959        assert!(outcome.is_success(), "scratch read: {outcome:?}");
1960        assert_eq!(outcome.output(), "stashed");
1961        let _ = fs::remove_dir_all(project.parent().unwrap());
1962    }
1963
1964    #[tokio::test]
1965    async fn edit_file_replaces_target_content_successfully() {
1966        let (project, _scratch) = scratch_fixture("edit_success");
1967        let file = project.join("src").join("main.rs");
1968        fs::create_dir_all(file.parent().unwrap()).unwrap();
1969        fs::write(&file, "fn main() {\n    println!(\"old\");\n}\n").unwrap();
1970
1971        let (ctx, _rx) = scratch_ctx(
1972            mermaid_runtime::SafetyMode::FullAccess,
1973            project.clone(),
1974            None,
1975        );
1976        let outcome = EditFileTool
1977            .execute(
1978                serde_json::json!({
1979                    "path": "src/main.rs",
1980                    "target_content": "    println!(\"old\");",
1981                    "replacement_content": "    println!(\"new\");",
1982                }),
1983                ctx,
1984            )
1985            .await;
1986
1987        assert!(outcome.is_success(), "edit outcome: {outcome:?}");
1988        let new_content = fs::read_to_string(&file).unwrap();
1989        assert_eq!(new_content, "fn main() {\n    println!(\"new\");\n}\n");
1990        let diff = outcome
1991            .metadata
1992            .display_diff
1993            .as_deref()
1994            .expect("display diff");
1995        assert!(diff.contains("-     println!(\"old\");"));
1996        assert!(diff.contains("+     println!(\"new\");"));
1997        let _ = fs::remove_dir_all(project.parent().unwrap());
1998    }
1999
2000    #[tokio::test]
2001    async fn edit_file_target_not_found_returns_error() {
2002        let (project, _scratch) = scratch_fixture("edit_not_found");
2003        let file = project.join("hello.txt");
2004        fs::write(&file, "line 1\nline 2\n").unwrap();
2005
2006        let (ctx, _rx) = scratch_ctx(
2007            mermaid_runtime::SafetyMode::FullAccess,
2008            project.clone(),
2009            None,
2010        );
2011        let outcome = EditFileTool
2012            .execute(
2013                serde_json::json!({
2014                    "path": "hello.txt",
2015                    "target_content": "nonexistent text",
2016                    "replacement_content": "replacement",
2017                }),
2018                ctx,
2019            )
2020            .await;
2021
2022        assert!(!outcome.is_success());
2023        let err = outcome.error_message().unwrap();
2024        assert!(err.contains("could not find target_content"), "{err}");
2025        let _ = fs::remove_dir_all(project.parent().unwrap());
2026    }
2027
2028    #[tokio::test]
2029    async fn edit_file_ambiguous_target_errors_unless_allow_multiple() {
2030        let (project, _scratch) = scratch_fixture("edit_ambig");
2031        let file = project.join("dup.txt");
2032        fs::write(&file, "foo\nbar\nfoo\n").unwrap();
2033
2034        let (ctx, _rx) = scratch_ctx(
2035            mermaid_runtime::SafetyMode::FullAccess,
2036            project.clone(),
2037            None,
2038        );
2039        let outcome_ambig = EditFileTool
2040            .execute(
2041                serde_json::json!({
2042                    "path": "dup.txt",
2043                    "target_content": "foo",
2044                    "replacement_content": "baz",
2045                }),
2046                ctx,
2047            )
2048            .await;
2049        assert!(!outcome_ambig.is_success());
2050        let err = outcome_ambig.error_message().unwrap();
2051        assert!(err.contains("found 2 times"), "{err}");
2052
2053        let (ctx2, _rx2) = scratch_ctx(
2054            mermaid_runtime::SafetyMode::FullAccess,
2055            project.clone(),
2056            None,
2057        );
2058        let outcome_allow = EditFileTool
2059            .execute(
2060                serde_json::json!({
2061                    "path": "dup.txt",
2062                    "target_content": "foo",
2063                    "replacement_content": "baz",
2064                    "allow_multiple": true,
2065                }),
2066                ctx2,
2067            )
2068            .await;
2069        assert!(outcome_allow.is_success());
2070        let new_content = fs::read_to_string(&file).unwrap();
2071        assert_eq!(new_content, "baz\nbar\nbaz\n");
2072        let _ = fs::remove_dir_all(project.parent().unwrap());
2073    }
2074
2075    #[tokio::test]
2076    async fn edit_file_fuzzy_whitespace_matching_succeeds() {
2077        let (project, _scratch) = scratch_fixture("edit_fuzzy");
2078        let file = project.join("fuzzy.txt");
2079        fs::write(&file, "start\n    loose_whitespace();   \nend\n").unwrap();
2080
2081        let (ctx, _rx) = scratch_ctx(
2082            mermaid_runtime::SafetyMode::FullAccess,
2083            project.clone(),
2084            None,
2085        );
2086        let outcome = EditFileTool
2087            .execute(
2088                serde_json::json!({
2089                    "path": "fuzzy.txt",
2090                    "target_content": "loose_whitespace();",
2091                    "replacement_content": "    tight_whitespace();",
2092                }),
2093                ctx,
2094            )
2095            .await;
2096        assert!(outcome.is_success());
2097        assert!(outcome.output().contains("fuzzy"));
2098        let new_content = fs::read_to_string(&file).unwrap();
2099        assert_eq!(new_content, "start\n    tight_whitespace();\nend\n");
2100        let _ = fs::remove_dir_all(project.parent().unwrap());
2101    }
2102
2103    // ─── Reads outside the project go through the policy gate ─────────────
2104
2105    /// A project dir plus a file that sits outside every allowed root.
2106    fn external_read_fixture(name: &str) -> (PathBuf, PathBuf) {
2107        let workdir = temp_root(&format!("{name}_project"));
2108        let external_dir = temp_root(&format!("{name}_external"));
2109        let external_file = external_dir.join("ext.txt");
2110        fs::write(&external_file, "external content").unwrap();
2111        (workdir, external_file)
2112    }
2113
2114    fn ctx_in_mode(
2115        mode: mermaid_runtime::SafetyMode,
2116        workdir: PathBuf,
2117    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2118        let mut config = mermaid_domain::Config::default();
2119        config.safety.mode = mode;
2120        crate::providers::ctx::test_exec_context_with_config(
2121            TurnId(1),
2122            ToolCallId(1),
2123            workdir,
2124            config,
2125        )
2126    }
2127
2128    async fn read_external(ctx: ExecContext, external_file: &Path) -> ToolOutcome {
2129        ReadFileTool
2130            .execute(
2131                serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
2132                ctx,
2133            )
2134            .await
2135    }
2136
2137    /// The read-only floor: an out-of-project read is an external side effect,
2138    /// denied in `read_only` and in plan mode. Before the gate, `read_file`
2139    /// handed back `~/.ssh/id_rsa` in every mode without a prompt.
2140    #[tokio::test]
2141    async fn read_outside_the_project_is_denied_under_the_read_only_floor() {
2142        for mode in [
2143            mermaid_runtime::SafetyMode::ReadOnly,
2144            mermaid_runtime::SafetyMode::Plan,
2145        ] {
2146            let (workdir, external_file) = external_read_fixture("read_ext_readonly");
2147            let (ctx, _rx) = ctx_in_mode(mode, workdir.clone());
2148            let outcome = read_external(ctx, &external_file).await;
2149            assert_eq!(
2150                outcome.status,
2151                mermaid_domain::ToolStatus::Error,
2152                "{mode:?} must deny an external read: {outcome:?}"
2153            );
2154            assert!(
2155                !outcome.output().contains("external content"),
2156                "{mode:?} leaked the file body: {}",
2157                outcome.output()
2158            );
2159            let _ = fs::remove_dir_all(&workdir);
2160            let _ = fs::remove_dir_all(external_file.parent().unwrap());
2161        }
2162    }
2163
2164    /// Inside the project nothing changes: a `read_only` session still reads
2165    /// its own files without a prompt.
2166    #[tokio::test]
2167    async fn read_inside_the_project_stays_ungated_in_read_only_mode() {
2168        let workdir = temp_root("read_inside_readonly");
2169        fs::write(workdir.join("notes.md"), "project content").unwrap();
2170        let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2171        let outcome = ReadFileTool
2172            .execute(serde_json::json!({"path": "notes.md"}), ctx)
2173            .await;
2174        assert_eq!(
2175            outcome.status,
2176            mermaid_domain::ToolStatus::Success,
2177            "{outcome:?}"
2178        );
2179        assert_eq!(outcome.output(), "project content");
2180        let _ = fs::remove_dir_all(&workdir);
2181    }
2182
2183    /// `ask` mode prompts, and the prompt's "don't ask again" scope is the
2184    /// file's directory — never the whole tool, so approving one external
2185    /// read cannot silently cover a credential file later in the session.
2186    #[tokio::test]
2187    async fn read_outside_the_project_prompts_in_ask_mode_scoped_to_its_directory() {
2188        let (workdir, external_file) = external_read_fixture("read_ext_ask");
2189        let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2190        let broker = crate::providers::ApprovalBroker::new(tx);
2191        let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2192        ctx.approval = Some(broker.clone());
2193        let file_for_task = external_file.clone();
2194        let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2195
2196        let (call_id, tool, scope) = match rx.recv().await.expect("approval requested") {
2197            mermaid_domain::Msg::ApprovalRequested {
2198                call_id,
2199                tool,
2200                allowlist_scope,
2201                ..
2202            } => (call_id, tool, allowlist_scope),
2203            other => panic!("expected ApprovalRequested, got {other:?}"),
2204        };
2205        assert_eq!(tool, "read_file");
2206        let expected_dir = external_file.canonicalize().unwrap();
2207        let expected_dir = expected_dir.parent().unwrap();
2208        assert_eq!(
2209            scope,
2210            format!("read_file:{}", expected_dir.display()),
2211            "external reads are allowlisted per directory"
2212        );
2213
2214        broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
2215        let outcome = handle.await.unwrap();
2216        assert_eq!(
2217            outcome.status,
2218            mermaid_domain::ToolStatus::Success,
2219            "{outcome:?}"
2220        );
2221        assert_eq!(outcome.output(), "external content");
2222        let _ = fs::remove_dir_all(&workdir);
2223        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2224    }
2225
2226    /// The user's "No" is final: the read fails and the body never leaves disk.
2227    #[tokio::test]
2228    async fn read_outside_the_project_denied_by_the_user_is_an_error() {
2229        let (workdir, external_file) = external_read_fixture("read_ext_deny");
2230        let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2231        let broker = crate::providers::ApprovalBroker::new(tx);
2232        let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2233        ctx.approval = Some(broker.clone());
2234        let file_for_task = external_file.clone();
2235        let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2236        let call_id = match rx.recv().await.expect("approval requested") {
2237            mermaid_domain::Msg::ApprovalRequested { call_id, .. } => call_id,
2238            other => panic!("expected ApprovalRequested, got {other:?}"),
2239        };
2240        broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
2241        let outcome = handle.await.unwrap();
2242        assert_eq!(
2243            outcome.status,
2244            mermaid_domain::ToolStatus::Error,
2245            "{outcome:?}"
2246        );
2247        assert!(!outcome.output().contains("external content"));
2248        let _ = fs::remove_dir_all(&workdir);
2249        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2250    }
2251
2252    /// A symlink planted inside the project that resolves outside it is an
2253    /// external read: the resolver canonicalizes through the link, so the
2254    /// gate sees the real target, not the friendly-looking relative path.
2255    #[cfg(unix)]
2256    #[tokio::test]
2257    async fn a_symlink_inside_the_project_that_points_outside_is_gated() {
2258        let (workdir, external_file) = external_read_fixture("read_ext_symlink");
2259        std::os::unix::fs::symlink(&external_file, workdir.join("innocent.txt")).unwrap();
2260        let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2261        let outcome = ReadFileTool
2262            .execute(serde_json::json!({"path": "innocent.txt"}), ctx)
2263            .await;
2264        assert_eq!(
2265            outcome.status,
2266            mermaid_domain::ToolStatus::Error,
2267            "{outcome:?}"
2268        );
2269        assert!(!outcome.output().contains("external content"));
2270        let _ = fs::remove_dir_all(&workdir);
2271        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2272    }
2273
2274    /// `auto` mode classifies external writes instead of allowing them: with no
2275    /// classifier bound the gate escalates, so nothing lands outside the
2276    /// project unasked. Before this, an external write classified like an
2277    /// in-project edit and `auto` wrote it with a checkpoint and no question.
2278    #[tokio::test]
2279    async fn write_outside_the_project_is_not_silently_allowed_in_auto_mode() {
2280        let (project, scratch) = scratch_fixture("outside_auto");
2281        let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2282        let (ctx, _rx) = scratch_ctx(
2283            mermaid_runtime::SafetyMode::Auto,
2284            project.clone(),
2285            Some(scratch.clone()),
2286        );
2287        let outcome = WriteFileTool
2288            .execute(
2289                serde_json::json!({
2290                    "path": outside.to_str().unwrap(),
2291                    "content": "must not land unasked",
2292                }),
2293                ctx,
2294            )
2295            .await;
2296        assert_eq!(
2297            outcome.status,
2298            mermaid_domain::ToolStatus::Error,
2299            "auto mode must escalate an external write: {outcome:?}"
2300        );
2301        assert!(!outside.exists(), "the external file was written anyway");
2302        let _ = fs::remove_dir_all(project.parent().unwrap());
2303    }
2304
2305    /// The control: an in-project write in `auto` mode is still an ordinary
2306    /// edit and proceeds without a prompt.
2307    #[tokio::test]
2308    async fn write_inside_the_project_stays_allowed_in_auto_mode() {
2309        let (project, scratch) = scratch_fixture("inside_auto");
2310        let (ctx, _rx) = scratch_ctx(
2311            mermaid_runtime::SafetyMode::Auto,
2312            project.clone(),
2313            Some(scratch.clone()),
2314        );
2315        let outcome = WriteFileTool
2316            .execute(
2317                serde_json::json!({"path": "inside.txt", "content": "fine"}),
2318                ctx,
2319            )
2320            .await;
2321        assert_eq!(
2322            outcome.status,
2323            mermaid_domain::ToolStatus::Success,
2324            "{outcome:?}"
2325        );
2326        assert_eq!(
2327            fs::read_to_string(project.join("inside.txt")).unwrap(),
2328            "fine"
2329        );
2330        let _ = fs::remove_dir_all(project.parent().unwrap());
2331    }
2332}