Skip to main content

mermaid_cli/providers/tool/
filesystem.rs

1//! Filesystem tools ported to `ToolExecutor`.
2//!
3//! This is the proof-of-pattern tool impl for C3: `ReadFileTool` and
4//! `WriteFileTool`. They hook the `ExecContext::token` so Ctrl+C
5//! cancels mid-read (relevant for large files on slow storage), and
6//! they emit `ProgressEvent::Status` breadcrumbs for multi-file
7//! operations the old code couldn't surface without an observer
8//! callback.
9//!
10//! The implementations don't try to out-clever the existing tool
11//! behavior in `src/agents/filesystem.rs`. Same semantics, same error
12//! shapes — just wrapped in the new trait so future tools only have
13//! to learn this surface.
14
15use mermaid_domain::ProgressEvent;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19
20use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21use mermaid_model::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25use super::path_safety::{
26    AllowedRoots, PathContainment, ResolvedInRoot, relative_within, resolve_in_roots,
27    resolve_path_within,
28};
29
30/// Small helper for building a `ToolDefinition` with a typical
31/// JSON-schema-shaped `input_schema`. Keeps the per-tool definitions
32/// readable.
33fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
34    ToolDefinition {
35        name: name.to_string(),
36        description: description.to_string(),
37        input_schema,
38    }
39}
40
41/// Aggregate cap for a multi-file `read_file` result (#F45). Each file is
42/// individually bounded at `MAX_RESPONSE_CHARS` by `read_one`, but a batch of up
43/// to `MAX_BATCH_TOOL_ITEMS` files could otherwise sum to ~12.8 MB in a single
44/// tool result — far past any sane model-context budget. This bounds the
45/// combined total; single-file reads are already bounded and unaffected.
46const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
47
48/// Entries kept for same-turn duplicate-read suppression, across all live
49/// scopes. Hashes only — never content — so the bound is about map hygiene,
50/// not memory pressure.
51const READ_DEDUP_CAP: usize = 128;
52
53/// One remembered read: which context read which path, in which turn, and
54/// the content hash that proves the repeat is byte-identical.
55struct ReadDedupEntry {
56    scope: String,
57    path: String,
58    turn: u64,
59    hash: [u8; 32],
60    line_count: usize,
61}
62
63/// Same-turn duplicate reads, process-global (mirrors `web.rs`'s snapshot
64/// store). The `20260806` field logs show the same file read up to 14 times
65/// per session at full length — sometimes twice within one turn, where the
66/// earlier result is by construction still in the model's request. Only
67/// that provably-safe window is deduped: across turns a re-read may be a
68/// legitimate refresh (post-edit, post-compaction). Equality is proven by
69/// content hash — not mtime, which lies on some drives — so a change made
70/// by ANY path (`write_file`, `apply_patch`, `execute_command`, the user's
71/// editor) yields full content again with no invalidation hooks to forget.
72static READ_DEDUP: std::sync::OnceLock<
73    std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
74> = std::sync::OnceLock::new();
75
76/// The identity a dedup entry belongs to. Session/task ids separate
77/// concurrent contexts (a subagent's context is not the parent's — each
78/// must receive its own full read); the workdir separates anonymous test
79/// harness contexts, which reuse small turn ids.
80fn read_dedup_scope(ctx: &ExecContext) -> String {
81    format!(
82        "{}|{}|{}",
83        ctx.session_id.as_deref().unwrap_or(""),
84        ctx.task_id.as_deref().unwrap_or(""),
85        ctx.workdir.display(),
86    )
87}
88
89/// Returns the short reuse note when `content` is byte-identical to what an
90/// earlier read of `path` already returned THIS turn; otherwise records the
91/// read and returns `None` (full content flows). The note names the line
92/// count and the recovery paths so the model is taught, not stonewalled.
93fn duplicate_read_note(ctx: &ExecContext, path: &str, content: &str) -> Option<String> {
94    use sha2::{Digest, Sha256};
95    let hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
96    let line_count = content.lines().count();
97    let scope = read_dedup_scope(ctx);
98    let mut store = READ_DEDUP
99        .get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()))
100        .lock()
101        .unwrap_or_else(std::sync::PoisonError::into_inner);
102    if let Some(entry) = store
103        .iter_mut()
104        .find(|e| e.scope == scope && e.path == path)
105    {
106        let identical_this_turn = entry.turn == ctx.turn.0 && entry.hash == hash;
107        entry.turn = ctx.turn.0;
108        entry.hash = hash;
109        entry.line_count = line_count;
110        return identical_this_turn.then(|| {
111            format!(
112                "{path}: unchanged since your read earlier this turn — the full \
113                 content ({line_count} lines) is already in this turn's tool \
114                 results; reuse it. A read after the file changes, or in a later \
115                 turn, returns the full content again."
116            )
117        });
118    }
119    store.push_back(ReadDedupEntry {
120        scope,
121        path: path.to_string(),
122        turn: ctx.turn.0,
123        hash,
124        line_count,
125    });
126    if store.len() > READ_DEDUP_CAP {
127        store.pop_front();
128    }
129    None
130}
131
132/// `read_file` — read one or more files and return their contents
133/// joined with section markers.
134pub struct ReadFileTool;
135
136#[async_trait]
137impl ToolExecutor for ReadFileTool {
138    fn name(&self) -> &'static str {
139        "read_file"
140    }
141
142    fn schema(&self) -> ToolDefinition {
143        defn(
144            "read_file",
145            "Read the contents of one or more files from disk. Relative paths resolve relative to the project directory; absolute paths may resolve anywhere on disk.",
146            serde_json::json!({
147                "type": "object",
148                "properties": {
149                    "path": { "type": "string", "description": "File to read (single)." },
150                    "paths": {
151                        "type": "array",
152                        "items": { "type": "string" },
153                        "description": "Multiple files to read sequentially, in order."
154                    }
155                },
156                "oneOf": [
157                    { "required": ["path"] },
158                    { "required": ["paths"] }
159                ]
160            }),
161        )
162    }
163
164    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
165        let paths = match extract_paths(&args) {
166            Ok(p) => p,
167            Err(e) => return ToolOutcome::error(e, None),
168        };
169        if paths.is_empty() {
170            return ToolOutcome::error("read_file requires at least one path", None);
171        }
172        let view = match View::from_args(&args) {
173            Ok(view) => view,
174            Err(e) => return ToolOutcome::error(e, None),
175        };
176
177        let start = std::time::Instant::now();
178        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
179        let mut combined = String::new();
180        let mut any_truncated = false;
181
182        for (idx, raw_path) in paths.iter().enumerate() {
183            let target = match resolve_read_target(&roots, raw_path) {
184                Ok(target) => target,
185                Err(e) => {
186                    return ToolOutcome::error(
187                        format!("{raw_path}: {e}"),
188                        Some(start.elapsed().as_secs_f64()),
189                    );
190                },
191            };
192            // A path outside every allowed root is an external side effect:
193            // it goes through the policy gate before a byte is read, exactly
194            // as an external write does.
195            if let Some(abs) = &target.external
196                && let Some(blocked) = external_read_gate(&ctx, raw_path, abs).await
197            {
198                return blocked;
199            }
200            // Race the file read against the turn's cancel token. If
201            // the user Ctrl+C's mid-read, we bail immediately.
202            tokio::select! {
203                biased;
204                _ = ctx.token.cancelled() => {
205                    return ToolOutcome::cancelled();
206                },
207                read = read_target(target.root, target.rel) => {
208                    let read = read.and_then(|(content, was_truncated, is_dir)| {
209                        if is_dir {
210                            return Ok((content, was_truncated));
211                        }
212                        view.apply(&content)
213                            .map(|content| (content, was_truncated))
214                            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
215                    });
216                    match read {
217                        Ok((content, was_truncated)) => {
218                            // A byte-identical repeat of a read this same turn
219                            // collapses to a short reuse note — the earlier
220                            // result is still in the model's request. The note
221                            // is not a truncation: nothing was cut that isn't
222                            // already present in full.
223                            let (content, was_truncated) =
224                                duplicate_read_note(&ctx, raw_path, &content)
225                                    .map_or((content, was_truncated), |note| (note, false));
226                            any_truncated |= was_truncated;
227                            if paths.len() > 1 {
228                                let _ = ctx.progress.send(ProgressEvent::Status(
229                                    format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
230                                )).await;
231                                combined.push_str(&format!(
232                                    "=== {raw_path} ===\n{content}\n\n"
233                                ));
234                            } else {
235                                combined = content;
236                            }
237                        },
238                        Err(e) => {
239                            return ToolOutcome::error(
240                                format!("{raw_path}: {e}"),
241                                Some(start.elapsed().as_secs_f64()),
242                            );
243                        },
244                    }
245                },
246            }
247        }
248
249        // F45: bound the COMBINED multi-file result. Each file is already capped
250        // at MAX_RESPONSE_CHARS by read_one, but a batch of files can still sum to
251        // ~12.8 MB in one tool result — past any sane context budget. Only the
252        // multi-file accumulation needs this (single-file output is already
253        // bounded); truncate_middle keeps the head AND tail with an elision marker.
254        if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
255            combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
256            any_truncated = true;
257        }
258
259        let duration_secs = start.elapsed().as_secs_f64();
260        let line_count = combined.lines().count();
261        let byte_count = combined.len();
262        // The REAL truncation flag from the bounded read — not a sniff for the
263        // marker string, which a file containing that literal text would
264        // falsely trip (#78).
265        let truncated = any_truncated;
266        ToolOutcome::success(
267            combined,
268            format!(
269                "{} {} read",
270                line_count,
271                plural(line_count, "line", "lines")
272            ),
273            duration_secs,
274        )
275        .with_metadata(ToolRunMetadata {
276            detail: ToolMetadata::ReadFile {
277                paths,
278                line_count,
279                byte_count,
280                truncated,
281            },
282            line_count: Some(line_count),
283            byte_count: Some(byte_count),
284            ..ToolRunMetadata::default()
285        })
286    }
287}
288
289/// `delete_file` — unlink a file. Errors on directories (use
290/// `execute_command rm -rf` for those — the model shouldn't be
291/// blowing away directories as a routine op).
292pub struct DeleteFileTool;
293
294#[async_trait]
295impl ToolExecutor for DeleteFileTool {
296    fn name(&self) -> &'static str {
297        "delete_file"
298    }
299
300    fn schema(&self) -> ToolDefinition {
301        defn(
302            "delete_file",
303            "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.",
304            serde_json::json!({
305                "type": "object",
306                "properties": { "path": { "type": "string" } },
307                "required": ["path"]
308            }),
309        )
310    }
311
312    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
313        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
314            return err("delete_file requires 'path'", 0.0);
315        };
316        let start = std::time::Instant::now();
317        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
318        let ResolvedInRoot {
319            abs,
320            rel,
321            root,
322            containment,
323        } = match resolve_in_roots(&roots, raw_path) {
324            Ok(r) => r,
325            Err(e) => return err(&format!("delete_file: {e}"), 0.0),
326        };
327        let pending_action = serde_json::json!({
328            "tool": "delete_file",
329            "args": { "path": raw_path },
330            "workdir": ctx.workdir.display().to_string(),
331            "turn_id": ctx.turn.0,
332            "call_id": ctx.call_id.0,
333            "task_id": ctx.task_id.clone(),
334        });
335        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
336            &ctx,
337            "delete_file",
338            raw_path,
339            std::slice::from_ref(&abs),
340            pending_action,
341            containment,
342        )
343        .await
344        {
345            return *outcome;
346        }
347        // Serialize writers to this canonical path: sibling tool calls in the same
348        // turn run concurrently, so without this the checkpoint + delete could race
349        // another writer to the same file. Distinct paths still overlap. Raced
350        // against cancellation so a contended lock stays Ctrl+C-responsive.
351        let _write_guard = tokio::select! {
352            biased;
353            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
354            g = super::path_lock::lock_path(&abs) => g,
355        };
356        // Scratchpad files are session-private and ephemeral — never
357        // checkpointed into the project's restore history.
358        if ctx.config.safety.checkpoint_on_mutation
359            && containment != PathContainment::Scratchpad
360            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
361                &ctx.workdir,
362                std::slice::from_ref(&abs),
363                Some(serde_json::json!({
364                    "tool": "delete_file",
365                    "path": raw_path,
366                })),
367                ctx.checkpoint_origin(),
368            )
369        {
370            return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
371        }
372        let display = raw_path.to_string();
373
374        tokio::select! {
375            biased;
376            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
377            result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
378                match result {
379                    Ok(Ok(())) => {
380                        let duration_secs = start.elapsed().as_secs_f64();
381                        after_file_mutation(&ctx, "delete_file", &display);
382                        ToolOutcome::success(
383                            format!("Deleted {display}"),
384                            "file deleted",
385                            duration_secs,
386                        )
387                        .with_metadata(ToolRunMetadata {
388                            detail: ToolMetadata::DeleteFile { path: display },
389                            ..ToolRunMetadata::default()
390                        })
391                    },
392                    Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
393                                       start.elapsed().as_secs_f64()),
394                    Err(e) => err(&format!("delete_file join error: {e}"),
395                                   start.elapsed().as_secs_f64()),
396                }
397            }
398        }
399    }
400}
401
402/// `create_directory` — `mkdir -p` semantics.
403pub struct CreateDirectoryTool;
404
405#[async_trait]
406impl ToolExecutor for CreateDirectoryTool {
407    fn name(&self) -> &'static str {
408        "create_directory"
409    }
410
411    fn schema(&self) -> ToolDefinition {
412        defn(
413            "create_directory",
414            "Create a directory (and any missing parents) at the given path. Paths may be relative to the project directory or absolute paths on disk.",
415            serde_json::json!({
416                "type": "object",
417                "properties": { "path": { "type": "string" } },
418                "required": ["path"]
419            }),
420        )
421    }
422
423    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
424        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
425            return err("create_directory requires 'path'", 0.0);
426        };
427        let start = std::time::Instant::now();
428        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
429        let ResolvedInRoot {
430            abs,
431            rel,
432            root,
433            containment,
434        } = match resolve_in_roots(&roots, raw_path) {
435            Ok(r) => r,
436            Err(e) => return err(&format!("create_directory: {e}"), 0.0),
437        };
438        let pending_action = serde_json::json!({
439            "tool": "create_directory",
440            "args": { "path": raw_path },
441            "workdir": ctx.workdir.display().to_string(),
442            "turn_id": ctx.turn.0,
443            "call_id": ctx.call_id.0,
444            "task_id": ctx.task_id.clone(),
445        });
446        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
447            &ctx,
448            "create_directory",
449            raw_path,
450            std::slice::from_ref(&abs),
451            pending_action,
452            containment,
453        )
454        .await
455        {
456            return *outcome;
457        }
458        // Serialize writers to this canonical path (see delete_file). mkdir -p is
459        // idempotent, but a uniform gate keeps ordering consistent and cheap.
460        let _write_guard = tokio::select! {
461            biased;
462            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
463            g = super::path_lock::lock_path(&abs) => g,
464        };
465        // Scratchpad dirs are session-private and ephemeral — never checkpointed.
466        if ctx.config.safety.checkpoint_on_mutation
467            && containment != PathContainment::Scratchpad
468            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
469                &ctx.workdir,
470                std::slice::from_ref(&abs),
471                Some(serde_json::json!({
472                    "tool": "create_directory",
473                    "path": raw_path,
474                })),
475                ctx.checkpoint_origin(),
476            )
477        {
478            return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
479        }
480        let display = raw_path.to_string();
481
482        tokio::select! {
483            biased;
484            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
485            result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
486                match result {
487                    Ok(Ok(())) => {
488                        let duration_secs = start.elapsed().as_secs_f64();
489                        after_file_mutation(&ctx, "create_directory", &display);
490                        ToolOutcome::success(
491                            format!("Created directory {display}"),
492                            "directory created",
493                            duration_secs,
494                        )
495                        .with_metadata(ToolRunMetadata {
496                            detail: ToolMetadata::CreateDirectory { path: display },
497                            ..ToolRunMetadata::default()
498                        })
499                    },
500                    Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
501                                       start.elapsed().as_secs_f64()),
502                    Err(e) => err(&format!("create_directory join error: {e}"),
503                                   start.elapsed().as_secs_f64()),
504                }
505            }
506        }
507    }
508}
509
510/// `write_file` — write a single file, creating parent dirs as needed.
511pub struct WriteFileTool;
512
513#[async_trait]
514impl ToolExecutor for WriteFileTool {
515    fn name(&self) -> &'static str {
516        "write_file"
517    }
518
519    fn schema(&self) -> ToolDefinition {
520        defn(
521            "write_file",
522            "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.",
523            serde_json::json!({
524                "type": "object",
525                "properties": {
526                    "path": { "type": "string" },
527                    "content": { "type": "string" }
528                },
529                "required": ["path", "content"]
530            }),
531        )
532    }
533
534    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
535        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
536            return ToolOutcome::error("write_file requires 'path' (string)", None);
537        };
538        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
539            return ToolOutcome::error("write_file requires 'content' (string)", None);
540        };
541
542        let start = std::time::Instant::now();
543        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
544        // `rel` is the root-relative name for the confined fd write (the actual
545        // byte path).
546        let ResolvedInRoot {
547            abs: abs_path,
548            rel,
549            root,
550            containment,
551        } = match resolve_in_roots(&roots, path) {
552            Ok(r) => r,
553            Err(e) => return ToolOutcome::error(format!("write_file: {e}"), None),
554        };
555        let pending_action = serde_json::json!({
556            "tool": "write_file",
557            "args": { "path": path, "content": content },
558            "workdir": ctx.workdir.display().to_string(),
559            "turn_id": ctx.turn.0,
560            "call_id": ctx.call_id.0,
561            "task_id": ctx.task_id.clone(),
562        });
563        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
564            &ctx,
565            "write_file",
566            path,
567            std::slice::from_ref(&abs_path),
568            pending_action,
569            containment,
570        )
571        .await
572        {
573            return *outcome;
574        }
575        // Serialize writers to this canonical path: two write_file/edit calls to
576        // the same file in one turn run concurrently, so without this the last
577        // atomic rename silently wins (lost update). Distinct paths still overlap.
578        // The owned guard is Send and held across the spawn_blocking below.
579        let _write_guard = tokio::select! {
580            biased;
581            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
582            g = super::path_lock::lock_path(&abs_path) => g,
583        };
584        // Scratchpad files are session-private and ephemeral — never checkpointed.
585        if ctx.config.safety.checkpoint_on_mutation
586            && containment != PathContainment::Scratchpad
587            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
588                &ctx.workdir,
589                std::slice::from_ref(&abs_path),
590                Some(serde_json::json!({
591                    "tool": "write_file",
592                    "path": path,
593                })),
594                ctx.checkpoint_origin(),
595            )
596        {
597            return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), None);
598        }
599        let display_path = path.to_string();
600        let line_count = content.lines().count();
601        let byte_count = content.len();
602        let content = content.to_string();
603
604        tokio::select! {
605            biased;
606            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
607            // The prior-content read (for the display diff) now happens INSIDE
608            // this blocking job and BOUNDED (#F44/RC-L) — never a synchronous
609            // unbounded `read_to_string` on the async worker thread.
610            result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
611                match result {
612                    Ok(Ok(write)) => {
613                        let duration_secs = start.elapsed().as_secs_f64();
614                        after_file_mutation(&ctx, "write_file", &display_path);
615                        ToolOutcome::success(
616                            format!("Wrote {} ({} lines)", display_path, write.line_count),
617                            format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
618                            duration_secs,
619                        )
620                        .with_metadata(ToolRunMetadata {
621                            detail: ToolMetadata::WriteFile {
622                                path: display_path,
623                                line_count,
624                                byte_count,
625                                created: Some(write.created),
626                            },
627                            line_count: Some(line_count),
628                            byte_count: Some(byte_count),
629                            display_diff: Some(write.diff.display_diff),
630                            diff_truncated: write.diff.truncated,
631                            lines_added: write.diff.added,
632                            lines_removed: write.diff.removed,
633                            ..ToolRunMetadata::default()
634                        })
635                    },
636                    Ok(Err(e)) => ToolOutcome::error(
637                        format!("write_file({display_path}): {e}"),
638                        Some(start.elapsed().as_secs_f64()),
639                    ),
640                    Err(e) => ToolOutcome::error(
641                        format!("write_file join error: {e}"),
642                        Some(start.elapsed().as_secs_f64()),
643                    ),
644                }
645            }
646        }
647    }
648}
649
650/// `edit_file` — precise search-and-replace editing on a single file.
651pub struct EditFileTool;
652
653#[async_trait]
654impl ToolExecutor for EditFileTool {
655    fn name(&self) -> &'static str {
656        "edit_file"
657    }
658
659    fn schema(&self) -> ToolDefinition {
660        defn(
661            "edit_file",
662            "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.",
663            serde_json::json!({
664                "type": "object",
665                "properties": {
666                    "path": {
667                        "type": "string",
668                        "description": "Path to the file to edit."
669                    },
670                    "target_content": {
671                        "type": "string",
672                        "description": "The exact or near-exact block of text to replace. Must match uniquely in the file unless allow_multiple is true."
673                    },
674                    "replacement_content": {
675                        "type": "string",
676                        "description": "The new replacement text."
677                    },
678                    "allow_multiple": {
679                        "type": "boolean",
680                        "description": "If true, replace all occurrences of target_content instead of requiring uniqueness. Defaults to false."
681                    }
682                },
683                "required": ["path", "target_content", "replacement_content"]
684            }),
685        )
686    }
687
688    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
689        let start = std::time::Instant::now();
690        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
691            return ToolOutcome::error("edit_file requires 'path' (string)", None);
692        };
693        let edit = match Edit::from_args(&args) {
694            Ok(edit) => edit,
695            Err(e) => return ToolOutcome::error(e, None),
696        };
697
698        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
699        let ResolvedInRoot {
700            abs: abs_path,
701            rel,
702            root,
703            containment,
704        } = match resolve_in_roots(&roots, path) {
705            Ok(r) => r,
706            Err(e) => return ToolOutcome::error(format!("edit_file: {e}"), None),
707        };
708
709        let mut pending_args = edit.args();
710        pending_args["path"] = serde_json::json!(path);
711        let pending_action = serde_json::json!({
712            "tool": "edit_file",
713            "args": pending_args,
714            "workdir": ctx.workdir.display().to_string(),
715            "turn_id": ctx.turn.0,
716            "call_id": ctx.call_id.0,
717            "task_id": ctx.task_id.clone(),
718        });
719        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
720            &ctx,
721            "edit_file",
722            path,
723            std::slice::from_ref(&abs_path),
724            pending_action,
725            containment,
726        )
727        .await
728        {
729            return *outcome;
730        }
731
732        let _write_guard = tokio::select! {
733            biased;
734            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
735            g = super::path_lock::lock_path(&abs_path) => g,
736        };
737
738        if ctx.config.safety.checkpoint_on_mutation
739            && containment != PathContainment::Scratchpad
740            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
741                &ctx.workdir,
742                std::slice::from_ref(&abs_path),
743                Some(serde_json::json!({
744                    "tool": "edit_file",
745                    "path": path,
746                })),
747                ctx.checkpoint_origin(),
748            )
749        {
750            return ToolOutcome::error(format!("edit_file checkpoint failed: {e}"), None);
751        }
752
753        let display_path = path.to_string();
754        tokio::select! {
755            biased;
756            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
757            result = tokio::task::spawn_blocking(move || edit_file_blocking(&root, &rel, &edit)) => {
758                match result {
759                    Ok(Ok(edit)) => {
760                        let duration_secs = start.elapsed().as_secs_f64();
761                        after_file_mutation(&ctx, "edit_file", &display_path);
762                        edit_success_outcome(&display_path, edit, duration_secs)
763                    },
764                    Ok(Err(e)) => ToolOutcome::error(
765                        format!("edit_file({display_path}): {e}"),
766                        Some(start.elapsed().as_secs_f64()),
767                    ),
768                    Err(e) => ToolOutcome::error(
769                        format!("edit_file join error: {e}"),
770                        Some(start.elapsed().as_secs_f64()),
771                    ),
772                }
773            }
774        }
775    }
776}
777
778fn edit_success_outcome(display_path: &str, edit: EditResult, duration_secs: f64) -> ToolOutcome {
779    let fuzzy_note = if edit.fuzzy {
780        "\nnote: matched with fuzzy (whitespace/Unicode) context; verify the result."
781    } else {
782        ""
783    };
784    ToolOutcome::success(
785        format!("Edited {display_path}{fuzzy_note}"),
786        diff_summary(edit.diff.added, edit.diff.removed, duration_secs),
787        duration_secs,
788    )
789    .with_metadata(ToolRunMetadata {
790        detail: ToolMetadata::ApplyPatch {
791            added: Vec::new(),
792            modified: vec![display_path.to_string()],
793            deleted: Vec::new(),
794            renamed: Vec::new(),
795            fuzzy: edit.fuzzy,
796        },
797        display_diff: Some(edit.diff.display_diff),
798        diff_truncated: edit.diff.truncated,
799        lines_added: edit.diff.added,
800        lines_removed: edit.diff.removed,
801        ..ToolRunMetadata::default()
802    })
803}
804
805// ─── helpers ────────────────────────────────────────────────────────
806
807fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
808    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
809    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
810        reject_web_url(p)?;
811        return Ok(vec![p.to_string()]);
812    }
813    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
814        if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
815            return Err(format!(
816                "read_file: too many paths ({}); cap is {} per call — split the request",
817                arr.len(),
818                mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
819            ));
820        }
821        let mut out = Vec::with_capacity(arr.len());
822        for v in arr {
823            let Some(s) = v.as_str() else {
824                return Err("read_file 'paths' must be an array of strings".to_string());
825            };
826            reject_web_url(s)?;
827            out.push(s.to_string());
828        }
829        return Ok(out);
830    }
831    Err("read_file requires 'path' or 'paths'".to_string())
832}
833
834/// `read_file` reads the local filesystem, but models under a web-gated
835/// safety mode were observed pointing it at `https://` URLs and treating the
836/// result as a fetch — and the path-resolution error that came back said
837/// nothing about the actual mistake. Name the mistake and the right tool;
838/// whether `web_fetch` is available is then that tool's own story to tell.
839fn reject_web_url(path: &str) -> Result<(), String> {
840    let head: String = path
841        .trim_start()
842        .chars()
843        .take(8)
844        .collect::<String>()
845        .to_ascii_lowercase();
846    if head.starts_with("http://") || head.starts_with("https://") {
847        return Err(format!(
848            "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
849        ));
850    }
851    Ok(())
852}
853
854/// Read-only carve-out for memory facts. Global and project-private memory
855/// live under the OS data dir — outside both allowed roots — and the memory
856/// index tells the model to `read_file` the fact's path, so reads resolve
857/// against the memory roots too. Absolute paths only, with the same
858/// canonical (symlink-resolving) containment as the scratchpad arm. The
859/// write tools never consult this: memory mutation goes through the `memory`
860/// tool, where the policy gate can see it.
861fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
862    if !Path::new(raw).is_absolute() {
863        return None;
864    }
865    for (root, _scope) in crate::app::memory::memory_roots(workdir) {
866        if let Ok((_abs, true)) = resolve_path_within(&root, raw)
867            && let Ok(rel) = relative_within(&root, raw)
868        {
869            return Some((root, rel));
870        }
871    }
872    None
873}
874
875/// Where a `read_file` target lands once the resolver and the memory-root
876/// fallback have spoken.
877struct ReadTarget {
878    root: PathBuf,
879    rel: PathBuf,
880    /// `Some(abs)` when the target sits outside every allowed root and must
881    /// clear [`external_read_gate`] before it is opened.
882    external: Option<PathBuf>,
883}
884
885/// Resolve a read target through the canonical containment resolver, and
886/// answer its three-way verdict: project and scratchpad reads are ungated;
887/// durable-memory reads are ungated too (memory is agent-owned by design and
888/// lives outside the project); anything else outside the roots is external and
889/// carries its absolute path for the gate.
890///
891/// `Err` only when the path cannot be resolved at all (a workdir that will not
892/// canonicalize, or no existing ancestor) and is not a memory path either.
893fn resolve_read_target(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<ReadTarget> {
894    match resolve_in_roots(roots, raw) {
895        Ok(ResolvedInRoot {
896            abs,
897            rel,
898            root,
899            containment,
900        }) => match containment {
901            PathContainment::Project | PathContainment::Scratchpad => Ok(ReadTarget {
902                root,
903                rel,
904                external: None,
905            }),
906            PathContainment::External => Ok(resolve_in_memory_roots(roots.workdir, raw).map_or(
907                ReadTarget {
908                    root,
909                    rel,
910                    external: Some(abs),
911                },
912                |(root, rel)| ReadTarget {
913                    root,
914                    rel,
915                    external: None,
916                },
917            )),
918        },
919        Err(msg) => {
920            let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
921                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
922            Ok(ReadTarget {
923                root,
924                rel,
925                external: None,
926            })
927        },
928    }
929}
930
931/// Gate a read of `abs`, a path outside the project and the scratchpad.
932///
933/// Filed as `ToolCategory::ExternalDirectory` — an external side effect, the
934/// same class an out-of-project `working_dir` gets from the exec tool — so
935/// `ask` prompts (allowlistable per directory), `auto` consults the intent
936/// classifier, `full_access` proceeds, and read-only mode denies. Not replayable: a read has nothing to replay, so a
937/// headless `ask` session refuses it unless untrusted tools were allowed.
938///
939/// Returns the blocking outcome, or `None` to proceed.
940async fn external_read_gate(ctx: &ExecContext, raw: &str, abs: &Path) -> Option<ToolOutcome> {
941    let mut request = mermaid_runtime::ActionRequest::new(
942        "read_file",
943        mermaid_runtime::ToolCategory::ExternalDirectory,
944        format!("read_file {raw}"),
945    );
946    request.path = Some(abs.display().to_string());
947    let pending_action = serde_json::json!({
948        "tool": "read_file",
949        "args": { "path": raw },
950        "workdir": ctx.workdir.display().to_string(),
951        "turn_id": ctx.turn.0,
952        "call_id": ctx.call_id.0,
953        "task_id": ctx.task_id.clone(),
954    });
955    match super::policy_gate::gate(ctx, request, &[], pending_action, false, false).await {
956        super::policy_gate::Gate::Block(outcome) => Some(outcome),
957        super::policy_gate::Gate::Proceed { .. } => None,
958    }
959}
960
961/// How `read_file` presents a file: whole and as-is by default, or
962/// line-numbered and optionally cut to a line range. The numbered form is the
963/// text editor's `view` (see the Anthropic adapter's native tools), which the
964/// model reads line numbers from to aim an `insert`. Neither field is in the
965/// advertised schema: only that translation sends them.
966#[derive(Debug, Default)]
967struct View {
968    line_numbers: bool,
969    /// 1-based inclusive first and last line; `None` last means to the end.
970    range: Option<(usize, Option<usize>)>,
971}
972
973impl View {
974    fn from_args(args: &serde_json::Value) -> Result<Self, String> {
975        let line_numbers = args
976            .get("line_numbers")
977            .and_then(serde_json::Value::as_bool)
978            .unwrap_or(false);
979        let range = match args.get("view_range") {
980            None | Some(serde_json::Value::Null) => None,
981            Some(range) => {
982                let bad = || {
983                    format!(
984                        "view_range must be [start, end] with 1 <= start and end >= start or \
985                         end = -1, got {range}"
986                    )
987                };
988                let [start, end] = range.as_array().map(Vec::as_slice).ok_or_else(bad)? else {
989                    return Err(bad());
990                };
991                let start = start
992                    .as_u64()
993                    .filter(|s| *s >= 1)
994                    .and_then(|s| usize::try_from(s).ok())
995                    .ok_or_else(bad)?;
996                let end = match end.as_i64() {
997                    Some(-1) => None,
998                    Some(e) => Some(
999                        usize::try_from(e)
1000                            .ok()
1001                            .filter(|e| *e >= start)
1002                            .ok_or_else(bad)?,
1003                    ),
1004                    None => return Err(bad()),
1005                };
1006                Some((start, end))
1007            },
1008        };
1009        Ok(Self {
1010            line_numbers: line_numbers || range.is_some(),
1011            range,
1012        })
1013    }
1014
1015    fn apply(&self, content: &str) -> Result<String, String> {
1016        if !self.line_numbers {
1017            return Ok(content.to_string());
1018        }
1019        let total = content.lines().count();
1020        let (start, end) = self.range.unwrap_or((1, None));
1021        if start > total.max(1) {
1022            return Err(format!(
1023                "view_range starts at line {start}, but the file has {total} lines"
1024            ));
1025        }
1026        let end = end.unwrap_or(total).min(total);
1027        let mut out = String::new();
1028        for (idx, line) in content.lines().enumerate().take(end).skip(start - 1) {
1029            out.push_str(&format!("{:>6}\t{line}\n", idx + 1));
1030        }
1031        Ok(out)
1032    }
1033}
1034
1035/// Entries a directory listing shows before it stops.
1036const DIRECTORY_LISTING_CAP: usize = 1_000;
1037
1038/// Read `rel` beneath `root`: a file's (bounded) text, or for a directory a
1039/// listing two levels deep, skipping hidden and ignored entries. The flag says
1040/// which it was, so a listing is never line-numbered as if it were a file.
1041async fn read_target(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool, bool)> {
1042    let abs = root.join(&rel);
1043    if tokio::fs::metadata(&abs).await.is_ok_and(|m| m.is_dir()) {
1044        let listing = tokio::task::spawn_blocking(move || list_directory(&abs))
1045            .await
1046            .map_err(|e| std::io::Error::other(e.to_string()))?;
1047        return Ok((listing.0, listing.1, true));
1048    }
1049    let (content, truncated) = read_one(root, rel).await?;
1050    Ok((content, truncated, false))
1051}
1052
1053/// `dir`'s entries two levels deep, one relative path per line (directories
1054/// end in `/`), and whether the listing hit [`DIRECTORY_LISTING_CAP`].
1055fn list_directory(dir: &Path) -> (String, bool) {
1056    let mut entries: Vec<String> = ignore::WalkBuilder::new(dir)
1057        .max_depth(Some(2))
1058        .hidden(true)
1059        .build()
1060        .filter_map(Result::ok)
1061        .filter(|entry| entry.depth() > 0)
1062        .filter_map(|entry| {
1063            let rel = entry.path().strip_prefix(dir).ok()?.display().to_string();
1064            let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
1065            Some(if is_dir { format!("{rel}/") } else { rel })
1066        })
1067        .collect();
1068    entries.sort();
1069    let truncated = entries.len() > DIRECTORY_LISTING_CAP;
1070    entries.truncate(DIRECTORY_LISTING_CAP);
1071    let mut out = entries.join("\n");
1072    if truncated {
1073        out.push_str(&format!(
1074            "\n[TRUNCATED: listing stops at {DIRECTORY_LISTING_CAP} entries]"
1075        ));
1076    }
1077    (out, truncated)
1078}
1079
1080/// Read one file (bounded) from `rel` beneath `root`. Returns the (possibly
1081/// marker-footed) text and the REAL truncation flag from the bounded read, so
1082/// the caller propagates that rather than sniffing the output for the marker
1083/// string — which a file whose own content contains that literal text would
1084/// otherwise falsely trip (#78).
1085///
1086/// The root-relative path feeds the confined fd read, so the bytes come from
1087/// the inode the kernel resolved under `RESOLVE_BENEATH` rather than whatever
1088/// a concurrently-swapped symlink now points at (#77).
1089async fn read_one(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool)> {
1090    let result = tokio::task::spawn_blocking(move || {
1091        let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
1092        // Bounded read: never pull more than the cap (+1 probe byte) into RAM,
1093        // so a model pointing `read_file` at a multi-gigabyte file can't OOM the
1094        // process — a full read would have slurped the whole thing first (#15).
1095        let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
1096        let mut s = String::from_utf8_lossy(&data).into_owned();
1097        if truncated {
1098            // Char-boundary-safe truncation with a marker footer.
1099            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
1100            s.truncate(cut);
1101            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
1102        }
1103        Ok::<_, std::io::Error>((s, truncated))
1104    })
1105    .await
1106    .map_err(|e| std::io::Error::other(e.to_string()))??;
1107    Ok(result)
1108}
1109
1110/// Write `content` to `rel` beneath `root` (the project workdir or the session
1111/// scratchpad) through the symlink-confined *atomic* writer, creating parent
1112/// dirs the same confined way. The bytes are written to a temp and
1113/// `renameat`-swapped over the target, all beneath the directory fd the kernel
1114/// resolved under `RESOLVE_BENEATH`: a parent dir swapped for an escaping
1115/// symlink can't redirect the write (#77), and a crash/kill/disk-full
1116/// mid-write leaves the previous file intact rather than a truncated or
1117/// half-written one.
1118fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
1119    if let Some(parent) = rel.parent()
1120        && !parent.as_os_str().is_empty()
1121    {
1122        mermaid_runtime::create_dir_all_beneath(root, parent)?;
1123    }
1124    mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
1125    Ok(content.lines().count())
1126}
1127
1128struct WriteResult {
1129    line_count: usize,
1130    created: bool,
1131    diff: mermaid_model::diff::DisplayDiff,
1132}
1133
1134/// Write `content` and build the display diff against the prior file in ONE
1135/// blocking job (#F44/RC-L). The prior content is read BOUNDED via
1136/// [`mermaid_model::utils::read_file_capped`] — overwriting a multi-gigabyte file must
1137/// not slurp it into RAM on the async worker just to render a diff. A prior file
1138/// larger than the read cap (or otherwise unreadable) is elided from the diff
1139/// rather than read whole.
1140fn write_with_diff_blocking(
1141    root: &Path,
1142    abs_path: &Path,
1143    rel: &Path,
1144    content: &str,
1145) -> std::io::Result<WriteResult> {
1146    let (old_content, created, elide_diff) =
1147        match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
1148            Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
1149            // Existing file is past the read cap — don't pull it all into RAM.
1150            Ok((_, true)) => (String::new(), false, true),
1151            // Missing file → a fresh create; the diff shows the whole content added.
1152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1153            // Any other read error: don't fail the write over a diff preview.
1154            Err(_) => (String::new(), false, true),
1155        };
1156    let diff = if elide_diff {
1157        mermaid_model::diff::DisplayDiff {
1158            display_diff: format!(
1159                "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
1160            ),
1161            added: 0,
1162            removed: 0,
1163            truncated: true,
1164        }
1165    } else {
1166        mermaid_model::diff::generate_display_diff(&old_content, content)
1167    };
1168    let line_count = write_one_blocking(root, rel, content)?;
1169    Ok(WriteResult {
1170        line_count,
1171        created,
1172        diff,
1173    })
1174}
1175
1176struct EditResult {
1177    diff: mermaid_model::diff::DisplayDiff,
1178    fuzzy: bool,
1179}
1180
1181/// One `edit_file` change: a search-and-replace, or an insert after a line.
1182enum Edit {
1183    Replace {
1184        target: String,
1185        replacement: String,
1186        allow_multiple: bool,
1187    },
1188    Insert {
1189        after_line: usize,
1190        text: String,
1191    },
1192}
1193
1194impl Edit {
1195    /// The change `args` asks for. `insert_line` is the text editor's
1196    /// `insert` (see the Anthropic adapter's native tools): the text goes
1197    /// after that line instead of over a match. It is not in the advertised
1198    /// schema; only that translation sends it.
1199    fn from_args(args: &serde_json::Value) -> Result<Self, String> {
1200        let text = |key: &str| args.get(key).and_then(serde_json::Value::as_str);
1201        let replacement = text("replacement_content")
1202            .ok_or("edit_file requires 'replacement_content' (string)")?
1203            .to_string();
1204        if let Some(line) = args.get("insert_line") {
1205            let after_line = line
1206                .as_u64()
1207                .and_then(|l| usize::try_from(l).ok())
1208                .ok_or("edit_file 'insert_line' must be a non-negative line number")?;
1209            return Ok(Self::Insert {
1210                after_line,
1211                text: replacement,
1212            });
1213        }
1214        Ok(Self::Replace {
1215            target: text("target_content")
1216                .ok_or("edit_file requires 'target_content' (string)")?
1217                .to_string(),
1218            replacement,
1219            allow_multiple: args
1220                .get("allow_multiple")
1221                .and_then(serde_json::Value::as_bool)
1222                .unwrap_or(false),
1223        })
1224    }
1225
1226    /// The arguments that replay this change (without the path).
1227    fn args(&self) -> serde_json::Value {
1228        match self {
1229            Self::Replace {
1230                target,
1231                replacement,
1232                allow_multiple,
1233            } => serde_json::json!({
1234                "target_content": target,
1235                "replacement_content": replacement,
1236                "allow_multiple": allow_multiple,
1237            }),
1238            Self::Insert { after_line, text } => serde_json::json!({
1239                "insert_line": after_line,
1240                "replacement_content": text,
1241            }),
1242        }
1243    }
1244}
1245
1246fn edit_file_blocking(root: &Path, rel: &Path, edit: &Edit) -> Result<EditResult, String> {
1247    let file = mermaid_runtime::open_beneath(root, rel, mermaid_runtime::OpenIntent::Read)
1248        .map_err(|e| format!("cannot open file: {e}"))?;
1249    let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)
1250        .map_err(|e| format!("cannot read file: {e}"))?;
1251    if truncated {
1252        return Err(format!(
1253            "file exceeds maximum size limit ({MAX_FILE_READ_BYTES} bytes)"
1254        ));
1255    }
1256    let original = String::from_utf8_lossy(&data).into_owned();
1257    let applied = match edit {
1258        Edit::Replace {
1259            target,
1260            replacement,
1261            allow_multiple,
1262        } => mermaid_runtime::replace_content(&original, target, replacement, *allow_multiple),
1263        Edit::Insert { after_line, text } => {
1264            mermaid_runtime::insert_content(&original, *after_line, text)
1265        },
1266    }
1267    .map_err(|e| e.to_string())?;
1268
1269    write_one_blocking(root, rel, &applied.new_contents)
1270        .map_err(|e| format!("cannot write file: {e}"))?;
1271
1272    let diff = mermaid_model::diff::generate_display_diff(&original, &applied.new_contents);
1273    Ok(EditResult {
1274        diff,
1275        fuzzy: applied.fuzzy,
1276    })
1277}
1278
1279/// Outcome of gating a file mutation.
1280pub(super) enum MutationGate {
1281    /// Blocked — return this outcome verbatim. Boxed to keep the enum small.
1282    Blocked(Box<ToolOutcome>),
1283    Proceed,
1284}
1285
1286/// Gate a file mutation by where its target landed.
1287///
1288/// `containment` is the resolver's verdict for the path, matched here so that
1289/// every mutating tool answers the three-way question in one place:
1290/// - `Project`: an ordinary edit (`ToolCategory::Edit`).
1291/// - `Scratchpad`: the gate downgrades an `Ask`/`Classify` to proceed — scratch
1292///   files are session-private and ephemeral — while read-only mode and `Deny`
1293///   overrides still block it.
1294/// - `External`: a path outside both roots is an external side effect
1295///   (`ToolCategory::ExternalDirectory`), so it prompts in `ask`, goes through
1296///   the intent classifier in `auto`, and is denied by the read-only floor —
1297///   the same treatment an out-of-project `working_dir` gets from the exec
1298///   tool. Before this, an external write classified like an in-project one
1299///   and `auto` mode allowed it unasked.
1300pub(super) async fn mutation_policy_outcome(
1301    ctx: &ExecContext,
1302    tool: &str,
1303    path: &str,
1304    checkpoint_paths: &[PathBuf],
1305    pending_action: serde_json::Value,
1306    containment: PathContainment,
1307) -> MutationGate {
1308    let category = match containment {
1309        PathContainment::Project | PathContainment::Scratchpad => {
1310            mermaid_runtime::ToolCategory::Edit
1311        },
1312        PathContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
1313    };
1314    let mut request = mermaid_runtime::ActionRequest::new(tool, category, format!("{tool} {path}"));
1315    request.path = Some(path.to_string());
1316    // File mutations are replayable: an Ask decision checkpoints, records an
1317    // approval, and blocks (handled inside the gate).
1318    match super::policy_gate::gate(
1319        ctx,
1320        request,
1321        checkpoint_paths,
1322        pending_action,
1323        true,
1324        containment == PathContainment::Scratchpad,
1325    )
1326    .await
1327    {
1328        super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
1329        super::policy_gate::Gate::Proceed { .. } => {
1330            let _ = mermaid_runtime::run_plugin_hooks(
1331                "before_file_mutation",
1332                &serde_json::json!({
1333                    "task_id": ctx.task_id.clone(),
1334                    "turn_id": ctx.turn.0,
1335                    "call_id": ctx.call_id.0,
1336                    "tool": tool,
1337                    "path": path,
1338                }),
1339            );
1340            MutationGate::Proceed
1341        },
1342    }
1343}
1344
1345pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
1346    let _ = mermaid_runtime::run_plugin_hooks(
1347        "after_file_mutation",
1348        &serde_json::json!({
1349            "task_id": ctx.task_id.clone(),
1350            "turn_id": ctx.turn.0,
1351            "call_id": ctx.call_id.0,
1352            "tool": tool,
1353            "path": path,
1354        }),
1355    );
1356}
1357
1358fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
1359    ToolOutcome::error(msg, Some(duration_secs))
1360}
1361
1362fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
1363    if count == 1 { singular } else { plural }
1364}
1365
1366pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
1367    format!(
1368        "+{} -{}, took {}",
1369        added,
1370        removed,
1371        format_duration_for_diff(duration_secs)
1372    )
1373}
1374
1375fn format_duration_for_diff(seconds: f64) -> String {
1376    if seconds < 1.0 {
1377        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1378    } else if seconds < 10.0 {
1379        format!("{seconds:.1}s")
1380    } else {
1381        format!("{}s", seconds.round() as u64)
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use crate::providers::ctx::test_exec_context;
1389    use mermaid_domain::{ToolCallId, TurnId};
1390    use std::fs;
1391
1392    /// Memory facts live outside the project/scratchpad roots and the index
1393    /// tells the model to `read_file` them — reads must resolve against the
1394    /// memory roots (here the `ProjectShared` root, reached by putting the
1395    /// workdir in a subdir of the git root), while unrelated outside paths
1396    /// stay rejected.
1397    #[tokio::test]
1398    async fn read_file_resolves_memory_roots_read_only() {
1399        let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
1400        let _ = fs::remove_dir_all(&base);
1401        let repo = base.join("repo");
1402        let workdir = repo.join("src");
1403        fs::create_dir_all(&workdir).unwrap();
1404        fs::create_dir_all(repo.join(".git")).unwrap();
1405        let mem_dir = repo.join(".mermaid").join("memory");
1406        fs::create_dir_all(&mem_dir).unwrap();
1407        let fact = mem_dir.join("fact.md");
1408        fs::write(&fact, "the fact body").unwrap();
1409
1410        let roots = AllowedRoots::new(&workdir, None);
1411        // A memory path sits outside the project yet resolves ungated: memory
1412        // is agent-owned by design.
1413        let target = resolve_read_target(&roots, fact.to_str().unwrap()).unwrap();
1414        assert!(target.external.is_none(), "memory reads are not external");
1415        let (content, truncated) = read_one(target.root, target.rel).await.unwrap();
1416        assert!(!truncated);
1417        assert_eq!(content, "the fact body");
1418
1419        // A stray path outside every root resolves too, but carries its
1420        // absolute path for the gate: `execute` must not read it unasked.
1421        let stray = base.join("stray.txt");
1422        fs::write(&stray, "nope").unwrap();
1423        let target = resolve_read_target(&roots, stray.to_str().unwrap()).unwrap();
1424        assert_eq!(
1425            target.external.as_deref().map(|p| p.ends_with("stray.txt")),
1426            Some(true),
1427            "a path outside every root is external"
1428        );
1429
1430        let _ = fs::remove_dir_all(&base);
1431    }
1432
1433    #[test]
1434    fn resolve_in_roots_contains_to_workdir() {
1435        let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
1436        let _ = fs::remove_dir_all(&root);
1437        fs::create_dir_all(root.join("sub")).unwrap();
1438        let roots = AllowedRoots::new(&root, None);
1439
1440        // In-root existing + not-yet-existing targets resolve inside root.
1441        assert!(resolve_in_roots(&roots, "sub").is_ok());
1442        let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
1443        let canon_root = fs::canonicalize(&root).unwrap();
1444        assert!(resolved.abs.starts_with(&canon_root));
1445
1446        // External paths and relative .. escapes resolve successfully.
1447        assert!(resolve_in_roots(&roots, "../escape.txt").is_ok());
1448        let outside = std::env::temp_dir().join("definitely_outside.txt");
1449        let r = resolve_in_roots(&roots, &outside.display().to_string()).unwrap();
1450        assert!(r.abs.ends_with(Path::new("definitely_outside.txt")));
1451
1452        let _ = fs::remove_dir_all(&root);
1453    }
1454
1455    fn temp_root(name: &str) -> PathBuf {
1456        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
1457        let _ = fs::remove_dir_all(&p);
1458        fs::create_dir_all(&p).expect("create tmpdir");
1459        p
1460    }
1461
1462    #[tokio::test]
1463    async fn read_file_returns_content() {
1464        let dir = temp_root("read_ok");
1465        fs::write(dir.join("a.txt"), "hello").expect("write");
1466        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1467
1468        let tool = ReadFileTool;
1469        let outcome = tool
1470            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1471            .await;
1472        assert!(outcome.is_success(), "expected success: {outcome:?}");
1473        assert_eq!(outcome.output(), "hello");
1474        let _ = fs::remove_dir_all(&dir);
1475    }
1476
1477    /// The text editor's `view` arrives as a numbered, optionally ranged
1478    /// `read_file`; the numbers are what the model aims an `insert` with.
1479    #[tokio::test]
1480    async fn read_file_numbers_lines_and_cuts_ranges_for_the_text_editor() {
1481        let dir = temp_root("read_view");
1482        fs::write(dir.join("a.txt"), "one\ntwo\nthree\n").expect("write");
1483        let read = |args: serde_json::Value, call: u64| {
1484            let (ctx, _rx) = test_exec_context(TurnId(call), ToolCallId(call), dir.clone());
1485            async move { ReadFileTool.execute(args, ctx).await }
1486        };
1487        let all = read(
1488            serde_json::json!({"path": "a.txt", "line_numbers": true}),
1489            1,
1490        )
1491        .await;
1492        assert_eq!(all.output(), "     1\tone\n     2\ttwo\n     3\tthree\n");
1493        let ranged = read(
1494            serde_json::json!({"path": "a.txt", "view_range": [2, -1]}),
1495            2,
1496        )
1497        .await;
1498        assert_eq!(ranged.output(), "     2\ttwo\n     3\tthree\n");
1499        let one = read(
1500            serde_json::json!({"path": "a.txt", "view_range": [2, 2]}),
1501            3,
1502        )
1503        .await;
1504        assert_eq!(one.output(), "     2\ttwo\n");
1505        for bad in [
1506            serde_json::json!([0, 2]),
1507            serde_json::json!([3, 1]),
1508            serde_json::json!([9, -1]),
1509            serde_json::json!("1-2"),
1510        ] {
1511            let outcome = read(serde_json::json!({"path": "a.txt", "view_range": bad}), 4).await;
1512            assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error, "{bad}");
1513        }
1514        let plain = read(serde_json::json!({"path": "a.txt"}), 5).await;
1515        assert_eq!(plain.output(), "one\ntwo\nthree\n", "unchanged by default");
1516        let _ = fs::remove_dir_all(&dir);
1517    }
1518
1519    #[tokio::test]
1520    async fn read_file_lists_a_directory_two_levels_deep() {
1521        let dir = temp_root("read_dir");
1522        fs::create_dir_all(dir.join("src/deep/deeper")).expect("mkdir");
1523        fs::write(dir.join("src/lib.rs"), "").expect("write");
1524        fs::write(dir.join("src/deep/deeper/x.rs"), "").expect("write");
1525        fs::write(dir.join(".hidden"), "").expect("write");
1526        fs::write(dir.join("README.md"), "").expect("write");
1527        for (turn, args) in [
1528            serde_json::json!({"path": "."}),
1529            serde_json::json!({"path": ".", "line_numbers": true}),
1530        ]
1531        .into_iter()
1532        .enumerate()
1533        {
1534            let turn = TurnId(turn as u64 + 1);
1535            let (ctx, _rx) = test_exec_context(turn, ToolCallId(1), dir.clone());
1536            let outcome = ReadFileTool.execute(args, ctx).await;
1537            assert!(outcome.is_success(), "{outcome:?}");
1538            assert_eq!(
1539                outcome.output(),
1540                format!(
1541                    "README.md\nsrc/\nsrc{0}deep/\nsrc{0}lib.rs",
1542                    std::path::MAIN_SEPARATOR
1543                )
1544            );
1545        }
1546        let _ = fs::remove_dir_all(&dir);
1547    }
1548
1549    /// The text editor's `insert` arrives as an `edit_file` with
1550    /// `insert_line`: the text lands after that line, with no match needed.
1551    #[tokio::test]
1552    async fn edit_file_inserts_after_a_line() {
1553        let dir = temp_root("edit_insert");
1554        fs::write(dir.join("a.txt"), "one\ntwo\n").expect("write");
1555        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1556        let outcome = EditFileTool
1557            .execute(
1558                serde_json::json!({"path": "a.txt", "insert_line": 1, "replacement_content": "mid"}),
1559                ctx,
1560            )
1561            .await;
1562        assert!(outcome.is_success(), "{outcome:?}");
1563        assert_eq!(
1564            fs::read_to_string(dir.join("a.txt")).unwrap(),
1565            "one\nmid\ntwo\n"
1566        );
1567
1568        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1569        let past_end = EditFileTool
1570            .execute(
1571                serde_json::json!({"path": "a.txt", "insert_line": 9, "replacement_content": "x"}),
1572                ctx,
1573            )
1574            .await;
1575        assert_eq!(past_end.status, mermaid_domain::ToolStatus::Error);
1576        assert_eq!(
1577            fs::read_to_string(dir.join("a.txt")).unwrap(),
1578            "one\nmid\ntwo\n"
1579        );
1580        let _ = fs::remove_dir_all(&dir);
1581    }
1582
1583    #[tokio::test]
1584    async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
1585        // Observed in the field: a model under a web-gated safety mode fed
1586        // `read_file` an https:// URL and treated the reply as a fetch. The
1587        // rejection must name the right tool — and a plain local read next to
1588        // it must keep working (the guard cannot overmatch).
1589        let dir = temp_root("read_url");
1590        fs::write(dir.join("a.txt"), "hello").expect("write");
1591        for args in [
1592            serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1593            serde_json::json!({"path": "HTTP://example.com/x"}),
1594            serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1595        ] {
1596            let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1597            let outcome = ReadFileTool.execute(args, ctx).await;
1598            assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1599            let msg = outcome.error_message().unwrap_or_default();
1600            assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1601        }
1602        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1603        let outcome = ReadFileTool
1604            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1605            .await;
1606        assert!(outcome.is_success(), "plain local reads must still work");
1607        let _ = fs::remove_dir_all(&dir);
1608    }
1609
1610    #[tokio::test]
1611    async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1612        let dir = temp_root("read_dedup");
1613        fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1614
1615        // First read: full content.
1616        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1617        let outcome = ReadFileTool
1618            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1619            .await;
1620        assert_eq!(outcome.output(), "line one\nline two");
1621
1622        // Byte-identical repeat in the SAME turn: a short reuse note, not
1623        // the body again — the earlier result rides the same request.
1624        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1625        let outcome = ReadFileTool
1626            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1627            .await;
1628        assert!(outcome.is_success());
1629        assert!(
1630            outcome
1631                .output()
1632                .contains("unchanged since your read earlier this turn"),
1633            "{}",
1634            outcome.output()
1635        );
1636        assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1637        assert!(
1638            !outcome.output().contains("line two"),
1639            "the body must not repeat: {}",
1640            outcome.output()
1641        );
1642
1643        // Matched pair (a): the file CHANGED on disk — by any writer; here a
1644        // direct fs write stands in for write_file / apply_patch /
1645        // execute_command — so the same-turn re-read returns full content.
1646        // Content-hash equality is the invalidation; there is no hook to
1647        // forget.
1648        fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1649        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1650        let outcome = ReadFileTool
1651            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1652            .await;
1653        assert_eq!(
1654            outcome.output(),
1655            "line one\nline two\nline three",
1656            "a changed file must read in full"
1657        );
1658
1659        // ...and the byte-identical repeat of THAT read collapses again.
1660        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1661        let outcome = ReadFileTool
1662            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1663            .await;
1664        assert!(
1665            outcome.output().contains("unchanged since"),
1666            "{}",
1667            outcome.output()
1668        );
1669
1670        // Matched pair (b): a LATER turn always reads in full — a cross-turn
1671        // re-read may be a legitimate refresh (post-compaction, post-edit)
1672        // and is never suppressed.
1673        let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1674        let outcome = ReadFileTool
1675            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1676            .await;
1677        assert_eq!(outcome.output(), "line one\nline two\nline three");
1678
1679        let _ = fs::remove_dir_all(&dir);
1680    }
1681
1682    #[tokio::test]
1683    async fn read_file_missing_path_errors() {
1684        let dir = temp_root("read_missing_path");
1685        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1686        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1687        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1688        let _ = fs::remove_dir_all(&dir);
1689    }
1690
1691    #[tokio::test]
1692    async fn read_file_nonexistent_errors() {
1693        let dir = temp_root("read_nonex");
1694        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1695        let outcome = ReadFileTool
1696            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1697            .await;
1698        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1699        let _ = fs::remove_dir_all(&dir);
1700    }
1701
1702    #[tokio::test]
1703    async fn read_file_with_multiple_paths_joins_contents() {
1704        let dir = temp_root("read_multi");
1705        fs::write(dir.join("a.txt"), "alpha").expect("write");
1706        fs::write(dir.join("b.txt"), "beta").expect("write");
1707        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1708        let outcome = ReadFileTool
1709            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1710            .await;
1711        assert!(outcome.is_success(), "expected success: {outcome:?}");
1712        let output = outcome.output();
1713        assert!(output.contains("=== a.txt ==="));
1714        assert!(output.contains("alpha"));
1715        assert!(output.contains("=== b.txt ==="));
1716        assert!(output.contains("beta"));
1717        let _ = fs::remove_dir_all(&dir);
1718    }
1719
1720    #[tokio::test]
1721    async fn read_file_multi_aggregate_is_capped() {
1722        // F45: many files in one call can't blow past the aggregate cap. Each
1723        // file is under the per-file cap, but their sum exceeds the aggregate.
1724        let dir = temp_root("read_aggregate_cap");
1725        let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1726        fs::write(dir.join("a.txt"), &chunk).expect("write a");
1727        fs::write(dir.join("b.txt"), &chunk).expect("write b");
1728        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1729        let outcome = ReadFileTool
1730            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1731            .await;
1732        assert!(outcome.is_success(), "expected success: {outcome:?}");
1733        let output = outcome.output();
1734        assert!(
1735            output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1736            "combined must be capped, got {} bytes",
1737            output.len()
1738        );
1739        assert!(
1740            output.contains("elided"),
1741            "expected aggregate head+tail elision marker"
1742        );
1743        match &outcome.metadata.detail {
1744            ToolMetadata::ReadFile { truncated, .. } => {
1745                assert!(*truncated, "aggregate truncation must set truncated")
1746            },
1747            other => panic!("expected ReadFile metadata, got {other:?}"),
1748        }
1749        let _ = fs::remove_dir_all(&dir);
1750    }
1751
1752    #[tokio::test]
1753    async fn write_file_elides_diff_for_oversized_existing_file() {
1754        // F44: overwriting a file larger than the read cap must NOT slurp it into
1755        // RAM for a diff — the diff is elided with a marker instead.
1756        let dir = temp_root("write_oversized_diff");
1757        let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1758        fs::write(dir.join("big.txt"), &big).expect("write fixture");
1759        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1760        let outcome = WriteFileTool
1761            .execute(
1762                serde_json::json!({"path": "big.txt", "content": "small\n"}),
1763                ctx,
1764            )
1765            .await;
1766        assert!(outcome.is_success(), "expected success: {outcome:?}");
1767        let diff = outcome
1768            .metadata
1769            .display_diff
1770            .as_deref()
1771            .expect("display diff");
1772        assert!(
1773            diff.contains("diff preview skipped"),
1774            "expected elision marker, got: {diff}"
1775        );
1776        assert!(
1777            outcome.metadata.diff_truncated,
1778            "oversized diff must set diff_truncated"
1779        );
1780        match &outcome.metadata.detail {
1781            ToolMetadata::WriteFile { created, .. } => {
1782                assert_eq!(*created, Some(false), "existing file is not 'created'")
1783            },
1784            other => panic!("expected WriteFile metadata, got {other:?}"),
1785        }
1786        // The file was actually overwritten despite the elided diff.
1787        let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1788        assert_eq!(written, "small\n");
1789        let _ = fs::remove_dir_all(&dir);
1790    }
1791
1792    #[tokio::test]
1793    async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1794        // #78: a small file whose own content contains the truncation-marker
1795        // string must NOT be reported as truncated — the flag comes from the
1796        // bounded read now, not a substring sniff of the output.
1797        let dir = temp_root("read_marker_content");
1798        fs::write(
1799            dir.join("a.txt"),
1800            "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1801        )
1802        .expect("write");
1803        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1804
1805        let outcome = ReadFileTool
1806            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1807            .await;
1808        assert!(outcome.is_success(), "expected success: {outcome:?}");
1809        match &outcome.metadata.detail {
1810            ToolMetadata::ReadFile { truncated, .. } => assert!(
1811                !truncated,
1812                "a file whose content contains the marker must not be flagged truncated"
1813            ),
1814            other => panic!("expected ReadFile metadata, got {other:?}"),
1815        }
1816        let _ = fs::remove_dir_all(&dir);
1817    }
1818
1819    #[tokio::test]
1820    async fn read_file_respects_cancellation() {
1821        let dir = temp_root("read_cancel");
1822        // Write a huge file so the read is slow enough to race cancel.
1823        // Actually spawn_blocking on read is fast on tmpfs — this test
1824        // just verifies the select! arm compiles + the token trips
1825        // the cancel path when pre-cancelled.
1826        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1827        ctx.token.cancel();
1828        let outcome = ReadFileTool
1829            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1830            .await;
1831        assert!(outcome.was_cancelled());
1832        let _ = fs::remove_dir_all(&dir);
1833    }
1834
1835    #[tokio::test]
1836    async fn write_file_creates_and_counts_lines() {
1837        let dir = temp_root("write_ok");
1838        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1839        let outcome = WriteFileTool
1840            .execute(
1841                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1842                ctx,
1843            )
1844            .await;
1845        assert!(outcome.is_success(), "expected success: {outcome:?}");
1846        assert!(outcome.output().contains("3 lines"));
1847        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1848        assert!(written.contains("line1"));
1849        let _ = fs::remove_dir_all(&dir);
1850    }
1851
1852    #[tokio::test]
1853    async fn concurrent_write_file_same_path_serializes_cleanly() {
1854        // The per-path write gate must let two writes to the same file in one turn
1855        // both succeed and leave the file as exactly one clean write (never a
1856        // corrupt interleave), and must not deadlock.
1857        let dir = temp_root("write_race");
1858        let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1859        let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1860        let a = "AAAA\nAAAA\n";
1861        let b = "BBBB\nBBBB\n";
1862        let (o1, o2) = tokio::join!(
1863            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1864            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1865        );
1866        assert!(o1.is_success(), "first write failed: {o1:?}");
1867        assert!(o2.is_success(), "second write failed: {o2:?}");
1868        let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1869        assert!(
1870            final_content == a || final_content == b,
1871            "file must be exactly one clean write, got {final_content:?}"
1872        );
1873        let _ = fs::remove_dir_all(&dir);
1874    }
1875
1876    #[tokio::test]
1877    async fn write_file_new_file_records_added_display_diff() {
1878        let dir = temp_root("write_new_diff");
1879        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1880        let outcome = WriteFileTool
1881            .execute(
1882                serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1883                ctx,
1884            )
1885            .await;
1886        assert!(outcome.is_success(), "expected success: {outcome:?}");
1887        let diff = outcome
1888            .metadata
1889            .display_diff
1890            .as_deref()
1891            .expect("display diff");
1892        assert!(diff.contains("+ alpha"));
1893        assert!(diff.contains("+ beta"));
1894        // No unified-diff header clutter (`---`/`+++`/`@@`).
1895        assert!(
1896            !diff.contains("@@"),
1897            "diff should not carry hunk headers: {diff}"
1898        );
1899        assert!(!diff.contains("/dev/null"));
1900        let _ = fs::remove_dir_all(&dir);
1901    }
1902
1903    #[tokio::test]
1904    async fn write_file_existing_file_records_added_and_removed_display_diff() {
1905        let dir = temp_root("write_existing_diff");
1906        fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1907        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1908        let outcome = WriteFileTool
1909            .execute(
1910                serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1911                ctx,
1912            )
1913            .await;
1914        assert!(outcome.is_success(), "expected success: {outcome:?}");
1915        let diff = outcome
1916            .metadata
1917            .display_diff
1918            .as_deref()
1919            .expect("display diff");
1920        assert!(diff.contains("- old"));
1921        assert!(diff.contains("+ new"));
1922        let _ = fs::remove_dir_all(&dir);
1923    }
1924
1925    #[tokio::test]
1926    async fn write_file_creates_parent_dirs() {
1927        let dir = temp_root("write_parents");
1928        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1929        let outcome = WriteFileTool
1930            .execute(
1931                serde_json::json!({
1932                    "path": "sub/nested/out.txt",
1933                    "content": "deep",
1934                }),
1935                ctx,
1936            )
1937            .await;
1938        assert!(outcome.is_success(), "expected success: {outcome:?}");
1939        assert!(dir.join("sub/nested/out.txt").exists());
1940        let _ = fs::remove_dir_all(&dir);
1941    }
1942
1943    #[tokio::test]
1944    async fn write_file_missing_content_errors() {
1945        let dir = temp_root("write_missing");
1946        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1947        let outcome = WriteFileTool
1948            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1949            .await;
1950        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1951        let _ = fs::remove_dir_all(&dir);
1952    }
1953
1954    // ─── F10: absolute-path block ───────────────────────────────────
1955
1956    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1957    /// must fail with a clear "outside the project" error. The tool
1958    /// schema advertises this contract; before F10 it was a lie.
1959    /// Reading an absolute path outside workdir succeeds.
1960    #[tokio::test]
1961    async fn read_file_allows_absolute_path_outside_workdir() {
1962        let dir = temp_root("read_abs_escape");
1963        let external_dir = temp_root("read_abs_external");
1964        let external_file = external_dir.join("ext.txt");
1965        fs::write(&external_file, "external content").unwrap();
1966        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1967        let outcome = ReadFileTool
1968            .execute(
1969                serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
1970                ctx,
1971            )
1972            .await;
1973        assert!(outcome.is_success(), "expected success: {outcome:?}");
1974        assert_eq!(outcome.output(), "external content");
1975        let _ = fs::remove_dir_all(&dir);
1976        let _ = fs::remove_dir_all(&external_dir);
1977    }
1978
1979    /// Absolute path that lives INSIDE the workdir is allowed.
1980    #[tokio::test]
1981    async fn read_file_accepts_absolute_path_inside_workdir() {
1982        let dir = temp_root("read_abs_inside");
1983        let file = dir.join("hello.txt");
1984        fs::write(&file, "ok").expect("write fixture");
1985        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1986        let outcome = ReadFileTool
1987            .execute(
1988                serde_json::json!({"path": file.to_string_lossy().to_string()}),
1989                ctx,
1990            )
1991            .await;
1992        assert!(outcome.is_success(), "expected success: {outcome:?}");
1993        let _ = fs::remove_dir_all(&dir);
1994    }
1995
1996    /// Relative `..`-escape and external paths are supported.
1997    #[tokio::test]
1998    async fn write_file_allows_relative_parent_and_external_path() {
1999        let base = temp_root("write_dotdot_escape");
2000        let dir = base.join("project");
2001        fs::create_dir_all(&dir).unwrap();
2002        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
2003        let outcome = WriteFileTool
2004            .execute(
2005                serde_json::json!({
2006                    "path": "../escape.txt",
2007                    "content": "written outside",
2008                }),
2009                ctx,
2010            )
2011            .await;
2012        assert!(
2013            outcome.is_success(),
2014            "expected success for write to ../escape.txt"
2015        );
2016        let written = base.join("escape.txt");
2017        assert!(written.exists());
2018        assert_eq!(fs::read_to_string(&written).unwrap(), "written outside");
2019        let _ = fs::remove_dir_all(&base);
2020    }
2021
2022    /// `create_directory` needs the lexical-normalization fallback
2023    /// because the target doesn't exist yet (can't canonicalize).
2024    /// Verify the escape check still fires for non-existent targets.
2025    /// `create_directory` creates directories outside workdir when called.
2026    #[tokio::test]
2027    async fn create_directory_allows_absolute_path_outside_workdir() {
2028        let dir = temp_root("mkdir_abs_proj");
2029        let target = std::env::temp_dir().join(format!("mermaid_fs_target_{}", std::process::id()));
2030        let _ = fs::remove_dir_all(&target);
2031        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
2032        let outcome = CreateDirectoryTool
2033            .execute(
2034                serde_json::json!({"path": target.to_string_lossy().to_string()}),
2035                ctx,
2036            )
2037            .await;
2038        assert!(outcome.is_success(), "expected success: {outcome:?}");
2039        assert!(target.exists());
2040        let _ = fs::remove_dir_all(&target);
2041        let _ = fs::remove_dir_all(&dir);
2042    }
2043
2044    // ─── session-scratchpad dual root ────────────────────────────────
2045
2046    /// Build an `ExecContext` with an explicit safety mode, NO approval
2047    /// broker, and (optionally) a materialized scratchpad. Unlike
2048    /// `test_exec_context` (pinned to `FullAccess`) this exercises the gate.
2049    fn scratch_ctx(
2050        mode: mermaid_runtime::SafetyMode,
2051        workdir: PathBuf,
2052        scratchpad: Option<PathBuf>,
2053    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2054        let mut config = mermaid_domain::Config::default();
2055        config.safety.mode = mode;
2056        let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
2057            TurnId(1),
2058            ToolCallId(1),
2059            workdir,
2060            config,
2061        );
2062        ctx.scratchpad = scratchpad;
2063        (ctx, rx)
2064    }
2065
2066    /// Project + scratch fixture pair with a unique, greppable name.
2067    fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
2068        let base = std::env::temp_dir().join(format!(
2069            "mermaid_fs_scratch_{}_{}",
2070            name,
2071            std::process::id()
2072        ));
2073        let _ = fs::remove_dir_all(&base);
2074        let project = base.join("project");
2075        let scratch = base.join("scratch");
2076        fs::create_dir_all(&project).unwrap();
2077        fs::create_dir_all(&scratch).unwrap();
2078        (project, scratch)
2079    }
2080
2081    /// True when any checkpoint manifest on disk references `marker`. The
2082    /// fixture paths are unique per test+pid, so a hit can only come from
2083    /// the mutation under test.
2084    fn any_checkpoint_mentions(marker: &str) -> bool {
2085        let Ok(data) = mermaid_runtime::data_dir() else {
2086            return false;
2087        };
2088        let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
2089            return false;
2090        };
2091        entries.flatten().any(|entry| {
2092            fs::read_to_string(entry.path().join("manifest.json"))
2093                .is_ok_and(|manifest| manifest.contains(marker))
2094        })
2095    }
2096
2097    /// Scratchpad mutations proceed in Ask mode with NO approval broker
2098    /// bound (the gate is bypassed entirely) and never take a checkpoint.
2099    #[tokio::test]
2100    async fn scratch_mutations_are_ungated_and_never_checkpointed() {
2101        let (project, scratch) = scratch_fixture("ungated");
2102        let marker = scratch.display().to_string();
2103
2104        // write_file into the scratchpad via absolute path.
2105        let file = scratch.join("notes.txt");
2106        let (ctx, _rx) = scratch_ctx(
2107            mermaid_runtime::SafetyMode::Ask,
2108            project.clone(),
2109            Some(scratch.clone()),
2110        );
2111        let outcome = WriteFileTool
2112            .execute(
2113                serde_json::json!({
2114                    "path": file.to_str().unwrap(),
2115                    "content": "scratch note\n",
2116                }),
2117                ctx,
2118            )
2119            .await;
2120        assert!(outcome.is_success(), "scratch write: {outcome:?}");
2121        assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
2122
2123        // create_directory inside the scratchpad.
2124        let subdir = scratch.join("work/area");
2125        let (ctx, _rx) = scratch_ctx(
2126            mermaid_runtime::SafetyMode::Ask,
2127            project.clone(),
2128            Some(scratch.clone()),
2129        );
2130        let outcome = CreateDirectoryTool
2131            .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
2132            .await;
2133        assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
2134        assert!(subdir.is_dir());
2135
2136        // delete_file inside the scratchpad.
2137        let (ctx, _rx) = scratch_ctx(
2138            mermaid_runtime::SafetyMode::Ask,
2139            project.clone(),
2140            Some(scratch.clone()),
2141        );
2142        let outcome = DeleteFileTool
2143            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
2144            .await;
2145        assert!(outcome.is_success(), "scratch delete: {outcome:?}");
2146        assert!(!file.exists());
2147
2148        // None of the mutations checkpointed the ephemeral scratch paths.
2149        assert!(
2150            !any_checkpoint_mentions(&marker),
2151            "scratch mutation must not create a checkpoint"
2152        );
2153        let _ = fs::remove_dir_all(project.parent().unwrap());
2154    }
2155
2156    /// `ReadOnly` still blocks scratchpad mutations — the bypass only skips
2157    /// the approval flow, never the mode's mutation ban.
2158    #[tokio::test]
2159    async fn scratch_mutation_blocked_in_read_only() {
2160        let (project, scratch) = scratch_fixture("readonly");
2161        let file = scratch.join("blocked.txt");
2162        let (ctx, _rx) = scratch_ctx(
2163            mermaid_runtime::SafetyMode::ReadOnly,
2164            project.clone(),
2165            Some(scratch.clone()),
2166        );
2167        let outcome = WriteFileTool
2168            .execute(
2169                serde_json::json!({
2170                    "path": file.to_str().unwrap(),
2171                    "content": "nope",
2172                }),
2173                ctx,
2174            )
2175            .await;
2176        let error = outcome.error_message().expect("expected block");
2177        assert!(
2178            error.contains("blocked by policy"),
2179            "expected policy block, got: {error}"
2180        );
2181        assert!(!file.exists());
2182        let _ = fs::remove_dir_all(project.parent().unwrap());
2183    }
2184
2185    /// A path outside scratchpad in Ask mode requires approval.
2186    #[tokio::test]
2187    async fn write_outside_both_roots_requires_approval_in_ask_mode() {
2188        let (project, scratch) = scratch_fixture("outside");
2189        let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2190        let (ctx, _rx) = scratch_ctx(
2191            mermaid_runtime::SafetyMode::Ask,
2192            project.clone(),
2193            Some(scratch.clone()),
2194        );
2195        let outcome = WriteFileTool
2196            .execute(
2197                serde_json::json!({
2198                    "path": outside.to_str().unwrap(),
2199                    "content": "should require approval",
2200                }),
2201                ctx,
2202            )
2203            .await;
2204        let error = outcome.error_message().expect("expected approval required");
2205        assert!(
2206            error.contains("Approval required"),
2207            "expected approval block, got: {error}"
2208        );
2209        assert!(!outside.exists());
2210        let _ = fs::remove_dir_all(project.parent().unwrap());
2211    }
2212
2213    /// `read_file` follows a materialized scratchpad too.
2214    #[tokio::test]
2215    async fn read_file_reads_from_scratchpad() {
2216        let (project, scratch) = scratch_fixture("read");
2217        let file = scratch.join("stash.txt");
2218        fs::write(&file, "stashed").unwrap();
2219        let (ctx, _rx) = scratch_ctx(
2220            mermaid_runtime::SafetyMode::Ask,
2221            project.clone(),
2222            Some(scratch.clone()),
2223        );
2224        let outcome = ReadFileTool
2225            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
2226            .await;
2227        assert!(outcome.is_success(), "scratch read: {outcome:?}");
2228        assert_eq!(outcome.output(), "stashed");
2229        let _ = fs::remove_dir_all(project.parent().unwrap());
2230    }
2231
2232    #[tokio::test]
2233    async fn edit_file_replaces_target_content_successfully() {
2234        let (project, _scratch) = scratch_fixture("edit_success");
2235        let file = project.join("src").join("main.rs");
2236        fs::create_dir_all(file.parent().unwrap()).unwrap();
2237        fs::write(&file, "fn main() {\n    println!(\"old\");\n}\n").unwrap();
2238
2239        let (ctx, _rx) = scratch_ctx(
2240            mermaid_runtime::SafetyMode::FullAccess,
2241            project.clone(),
2242            None,
2243        );
2244        let outcome = EditFileTool
2245            .execute(
2246                serde_json::json!({
2247                    "path": "src/main.rs",
2248                    "target_content": "    println!(\"old\");",
2249                    "replacement_content": "    println!(\"new\");",
2250                }),
2251                ctx,
2252            )
2253            .await;
2254
2255        assert!(outcome.is_success(), "edit outcome: {outcome:?}");
2256        let new_content = fs::read_to_string(&file).unwrap();
2257        assert_eq!(new_content, "fn main() {\n    println!(\"new\");\n}\n");
2258        let diff = outcome
2259            .metadata
2260            .display_diff
2261            .as_deref()
2262            .expect("display diff");
2263        assert!(diff.contains("-     println!(\"old\");"));
2264        assert!(diff.contains("+     println!(\"new\");"));
2265        let _ = fs::remove_dir_all(project.parent().unwrap());
2266    }
2267
2268    #[tokio::test]
2269    async fn edit_file_target_not_found_returns_error() {
2270        let (project, _scratch) = scratch_fixture("edit_not_found");
2271        let file = project.join("hello.txt");
2272        fs::write(&file, "line 1\nline 2\n").unwrap();
2273
2274        let (ctx, _rx) = scratch_ctx(
2275            mermaid_runtime::SafetyMode::FullAccess,
2276            project.clone(),
2277            None,
2278        );
2279        let outcome = EditFileTool
2280            .execute(
2281                serde_json::json!({
2282                    "path": "hello.txt",
2283                    "target_content": "nonexistent text",
2284                    "replacement_content": "replacement",
2285                }),
2286                ctx,
2287            )
2288            .await;
2289
2290        assert!(!outcome.is_success());
2291        let err = outcome.error_message().unwrap();
2292        assert!(err.contains("could not find target_content"), "{err}");
2293        let _ = fs::remove_dir_all(project.parent().unwrap());
2294    }
2295
2296    #[tokio::test]
2297    async fn edit_file_ambiguous_target_errors_unless_allow_multiple() {
2298        let (project, _scratch) = scratch_fixture("edit_ambig");
2299        let file = project.join("dup.txt");
2300        fs::write(&file, "foo\nbar\nfoo\n").unwrap();
2301
2302        let (ctx, _rx) = scratch_ctx(
2303            mermaid_runtime::SafetyMode::FullAccess,
2304            project.clone(),
2305            None,
2306        );
2307        let outcome_ambig = EditFileTool
2308            .execute(
2309                serde_json::json!({
2310                    "path": "dup.txt",
2311                    "target_content": "foo",
2312                    "replacement_content": "baz",
2313                }),
2314                ctx,
2315            )
2316            .await;
2317        assert!(!outcome_ambig.is_success());
2318        let err = outcome_ambig.error_message().unwrap();
2319        assert!(err.contains("found 2 times"), "{err}");
2320
2321        let (ctx2, _rx2) = scratch_ctx(
2322            mermaid_runtime::SafetyMode::FullAccess,
2323            project.clone(),
2324            None,
2325        );
2326        let outcome_allow = EditFileTool
2327            .execute(
2328                serde_json::json!({
2329                    "path": "dup.txt",
2330                    "target_content": "foo",
2331                    "replacement_content": "baz",
2332                    "allow_multiple": true,
2333                }),
2334                ctx2,
2335            )
2336            .await;
2337        assert!(outcome_allow.is_success());
2338        let new_content = fs::read_to_string(&file).unwrap();
2339        assert_eq!(new_content, "baz\nbar\nbaz\n");
2340        let _ = fs::remove_dir_all(project.parent().unwrap());
2341    }
2342
2343    #[tokio::test]
2344    async fn edit_file_fuzzy_whitespace_matching_succeeds() {
2345        let (project, _scratch) = scratch_fixture("edit_fuzzy");
2346        let file = project.join("fuzzy.txt");
2347        fs::write(&file, "start\n    loose_whitespace();   \nend\n").unwrap();
2348
2349        let (ctx, _rx) = scratch_ctx(
2350            mermaid_runtime::SafetyMode::FullAccess,
2351            project.clone(),
2352            None,
2353        );
2354        let outcome = EditFileTool
2355            .execute(
2356                serde_json::json!({
2357                    "path": "fuzzy.txt",
2358                    "target_content": "loose_whitespace();",
2359                    "replacement_content": "    tight_whitespace();",
2360                }),
2361                ctx,
2362            )
2363            .await;
2364        assert!(outcome.is_success());
2365        assert!(outcome.output().contains("fuzzy"));
2366        let new_content = fs::read_to_string(&file).unwrap();
2367        assert_eq!(new_content, "start\n    tight_whitespace();\nend\n");
2368        let _ = fs::remove_dir_all(project.parent().unwrap());
2369    }
2370
2371    // ─── Reads outside the project go through the policy gate ─────────────
2372
2373    /// A project dir plus a file that sits outside every allowed root.
2374    fn external_read_fixture(name: &str) -> (PathBuf, PathBuf) {
2375        let workdir = temp_root(&format!("{name}_project"));
2376        let external_dir = temp_root(&format!("{name}_external"));
2377        let external_file = external_dir.join("ext.txt");
2378        fs::write(&external_file, "external content").unwrap();
2379        (workdir, external_file)
2380    }
2381
2382    fn ctx_in_mode(
2383        mode: mermaid_runtime::SafetyMode,
2384        workdir: PathBuf,
2385    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2386        let mut config = mermaid_domain::Config::default();
2387        config.safety.mode = mode;
2388        crate::providers::ctx::test_exec_context_with_config(
2389            TurnId(1),
2390            ToolCallId(1),
2391            workdir,
2392            config,
2393        )
2394    }
2395
2396    async fn read_external(ctx: ExecContext, external_file: &Path) -> ToolOutcome {
2397        ReadFileTool
2398            .execute(
2399                serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
2400                ctx,
2401            )
2402            .await
2403    }
2404
2405    /// The read-only floor: an out-of-project read is an external side effect,
2406    /// denied in `read_only`. Before the gate, `read_file` handed back
2407    /// `~/.ssh/id_rsa` in every mode without a prompt.
2408    #[tokio::test]
2409    async fn read_outside_the_project_is_denied_under_the_read_only_floor() {
2410        let (workdir, external_file) = external_read_fixture("read_ext_readonly");
2411        let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2412        let outcome = read_external(ctx, &external_file).await;
2413        assert_eq!(
2414            outcome.status,
2415            mermaid_domain::ToolStatus::Error,
2416            "read_only must deny an external read: {outcome:?}"
2417        );
2418        assert!(
2419            !outcome.output().contains("external content"),
2420            "read_only leaked the file body: {}",
2421            outcome.output()
2422        );
2423        let _ = fs::remove_dir_all(&workdir);
2424        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2425    }
2426
2427    /// Inside the project nothing changes: a `read_only` session still reads
2428    /// its own files without a prompt.
2429    #[tokio::test]
2430    async fn read_inside_the_project_stays_ungated_in_read_only_mode() {
2431        let workdir = temp_root("read_inside_readonly");
2432        fs::write(workdir.join("notes.md"), "project content").unwrap();
2433        let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2434        let outcome = ReadFileTool
2435            .execute(serde_json::json!({"path": "notes.md"}), ctx)
2436            .await;
2437        assert_eq!(
2438            outcome.status,
2439            mermaid_domain::ToolStatus::Success,
2440            "{outcome:?}"
2441        );
2442        assert_eq!(outcome.output(), "project content");
2443        let _ = fs::remove_dir_all(&workdir);
2444    }
2445
2446    /// `ask` mode prompts, and the prompt's "don't ask again" scope is the
2447    /// file's directory — never the whole tool, so approving one external
2448    /// read cannot silently cover a credential file later in the session.
2449    #[tokio::test]
2450    async fn read_outside_the_project_prompts_in_ask_mode_scoped_to_its_directory() {
2451        let (workdir, external_file) = external_read_fixture("read_ext_ask");
2452        let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2453        let broker = crate::providers::ApprovalBroker::new(tx);
2454        let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2455        ctx.approval = Some(broker.clone());
2456        let file_for_task = external_file.clone();
2457        let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2458
2459        let (call_id, tool, scope) = match rx.recv().await.expect("approval requested") {
2460            mermaid_domain::Msg::ApprovalRequested {
2461                call_id,
2462                tool,
2463                allowlist_scope,
2464                ..
2465            } => (call_id, tool, allowlist_scope),
2466            other => panic!("expected ApprovalRequested, got {other:?}"),
2467        };
2468        assert_eq!(tool, "read_file");
2469        let expected_dir = external_file.canonicalize().unwrap();
2470        let expected_dir = expected_dir.parent().unwrap();
2471        assert_eq!(
2472            scope,
2473            format!("read_file:{}", expected_dir.display()),
2474            "external reads are allowlisted per directory"
2475        );
2476
2477        broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
2478        let outcome = handle.await.unwrap();
2479        assert_eq!(
2480            outcome.status,
2481            mermaid_domain::ToolStatus::Success,
2482            "{outcome:?}"
2483        );
2484        assert_eq!(outcome.output(), "external content");
2485        let _ = fs::remove_dir_all(&workdir);
2486        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2487    }
2488
2489    /// The user's "No" is final: the read fails and the body never leaves disk.
2490    #[tokio::test]
2491    async fn read_outside_the_project_denied_by_the_user_is_an_error() {
2492        let (workdir, external_file) = external_read_fixture("read_ext_deny");
2493        let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2494        let broker = crate::providers::ApprovalBroker::new(tx);
2495        let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2496        ctx.approval = Some(broker.clone());
2497        let file_for_task = external_file.clone();
2498        let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2499        let call_id = match rx.recv().await.expect("approval requested") {
2500            mermaid_domain::Msg::ApprovalRequested { call_id, .. } => call_id,
2501            other => panic!("expected ApprovalRequested, got {other:?}"),
2502        };
2503        broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
2504        let outcome = handle.await.unwrap();
2505        assert_eq!(
2506            outcome.status,
2507            mermaid_domain::ToolStatus::Error,
2508            "{outcome:?}"
2509        );
2510        assert!(!outcome.output().contains("external content"));
2511        let _ = fs::remove_dir_all(&workdir);
2512        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2513    }
2514
2515    /// A symlink planted inside the project that resolves outside it is an
2516    /// external read: the resolver canonicalizes through the link, so the
2517    /// gate sees the real target, not the friendly-looking relative path.
2518    #[cfg(unix)]
2519    #[tokio::test]
2520    async fn a_symlink_inside_the_project_that_points_outside_is_gated() {
2521        let (workdir, external_file) = external_read_fixture("read_ext_symlink");
2522        std::os::unix::fs::symlink(&external_file, workdir.join("innocent.txt")).unwrap();
2523        let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2524        let outcome = ReadFileTool
2525            .execute(serde_json::json!({"path": "innocent.txt"}), ctx)
2526            .await;
2527        assert_eq!(
2528            outcome.status,
2529            mermaid_domain::ToolStatus::Error,
2530            "{outcome:?}"
2531        );
2532        assert!(!outcome.output().contains("external content"));
2533        let _ = fs::remove_dir_all(&workdir);
2534        let _ = fs::remove_dir_all(external_file.parent().unwrap());
2535    }
2536
2537    /// `auto` mode classifies external writes instead of allowing them: with no
2538    /// classifier bound the gate escalates, so nothing lands outside the
2539    /// project unasked. Before this, an external write classified like an
2540    /// in-project edit and `auto` wrote it with a checkpoint and no question.
2541    #[tokio::test]
2542    async fn write_outside_the_project_is_not_silently_allowed_in_auto_mode() {
2543        let (project, scratch) = scratch_fixture("outside_auto");
2544        let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2545        let (ctx, _rx) = scratch_ctx(
2546            mermaid_runtime::SafetyMode::Auto,
2547            project.clone(),
2548            Some(scratch.clone()),
2549        );
2550        let outcome = WriteFileTool
2551            .execute(
2552                serde_json::json!({
2553                    "path": outside.to_str().unwrap(),
2554                    "content": "must not land unasked",
2555                }),
2556                ctx,
2557            )
2558            .await;
2559        assert_eq!(
2560            outcome.status,
2561            mermaid_domain::ToolStatus::Error,
2562            "auto mode must escalate an external write: {outcome:?}"
2563        );
2564        assert!(!outside.exists(), "the external file was written anyway");
2565        let _ = fs::remove_dir_all(project.parent().unwrap());
2566    }
2567
2568    /// The control: an in-project write in `auto` mode is still an ordinary
2569    /// edit and proceeds without a prompt.
2570    #[tokio::test]
2571    async fn write_inside_the_project_stays_allowed_in_auto_mode() {
2572        let (project, scratch) = scratch_fixture("inside_auto");
2573        let (ctx, _rx) = scratch_ctx(
2574            mermaid_runtime::SafetyMode::Auto,
2575            project.clone(),
2576            Some(scratch.clone()),
2577        );
2578        let outcome = WriteFileTool
2579            .execute(
2580                serde_json::json!({"path": "inside.txt", "content": "fine"}),
2581                ctx,
2582            )
2583            .await;
2584        assert_eq!(
2585            outcome.status,
2586            mermaid_domain::ToolStatus::Success,
2587            "{outcome:?}"
2588        );
2589        assert_eq!(
2590            fs::read_to_string(project.join("inside.txt")).unwrap(),
2591            "fine"
2592        );
2593        let _ = fs::remove_dir_all(project.parent().unwrap());
2594    }
2595}