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, ResolvedInRoot, relative_within, resolve_in_roots, resolve_path_within,
27};
28
29/// Small helper for building a `ToolDefinition` with a typical
30/// JSON-schema-shaped `input_schema`. Keeps the per-tool definitions
31/// readable.
32fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
33    ToolDefinition {
34        name: name.to_string(),
35        description: description.to_string(),
36        input_schema,
37    }
38}
39
40/// Aggregate cap for a multi-file `read_file` result (#F45). Each file is
41/// individually bounded at `MAX_RESPONSE_CHARS` by `read_one`, but a batch of up
42/// to `MAX_BATCH_TOOL_ITEMS` files could otherwise sum to ~12.8 MB in a single
43/// tool result — far past any sane model-context budget. This bounds the
44/// combined total; single-file reads are already bounded and unaffected.
45const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
46
47/// Entries kept for same-turn duplicate-read suppression, across all live
48/// scopes. Hashes only — never content — so the bound is about map hygiene,
49/// not memory pressure.
50const READ_DEDUP_CAP: usize = 128;
51
52/// One remembered read: which context read which path, in which turn, and
53/// the content hash that proves the repeat is byte-identical.
54struct ReadDedupEntry {
55    scope: String,
56    path: String,
57    turn: u64,
58    hash: [u8; 32],
59    line_count: usize,
60}
61
62/// Same-turn duplicate reads, process-global (mirrors `web.rs`'s snapshot
63/// store). The `20260806` field logs show the same file read up to 14 times
64/// per session at full length — sometimes twice within one turn, where the
65/// earlier result is by construction still in the model's request. Only
66/// that provably-safe window is deduped: across turns a re-read may be a
67/// legitimate refresh (post-edit, post-compaction). Equality is proven by
68/// content hash — not mtime, which lies on some drives — so a change made
69/// by ANY path (`write_file`, `apply_patch`, `execute_command`, the user's
70/// editor) yields full content again with no invalidation hooks to forget.
71static READ_DEDUP: std::sync::OnceLock<
72    std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
73> = std::sync::OnceLock::new();
74
75/// The identity a dedup entry belongs to. Session/task ids separate
76/// concurrent contexts (a subagent's context is not the parent's — each
77/// must receive its own full read); the workdir separates anonymous test
78/// harness contexts, which reuse small turn ids.
79fn read_dedup_scope(ctx: &ExecContext) -> String {
80    format!(
81        "{}|{}|{}",
82        ctx.session_id.as_deref().unwrap_or(""),
83        ctx.task_id.as_deref().unwrap_or(""),
84        ctx.workdir.display(),
85    )
86}
87
88/// Returns the short reuse note when `content` is byte-identical to what an
89/// earlier read of `path` already returned THIS turn; otherwise records the
90/// read and returns `None` (full content flows). The note names the line
91/// count and the recovery paths so the model is taught, not stonewalled.
92fn duplicate_read_note(ctx: &ExecContext, path: &str, content: &str) -> Option<String> {
93    use sha2::{Digest, Sha256};
94    let hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
95    let line_count = content.lines().count();
96    let scope = read_dedup_scope(ctx);
97    let mut store = READ_DEDUP
98        .get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()))
99        .lock()
100        .unwrap_or_else(std::sync::PoisonError::into_inner);
101    if let Some(entry) = store
102        .iter_mut()
103        .find(|e| e.scope == scope && e.path == path)
104    {
105        let identical_this_turn = entry.turn == ctx.turn.0 && entry.hash == hash;
106        entry.turn = ctx.turn.0;
107        entry.hash = hash;
108        entry.line_count = line_count;
109        return identical_this_turn.then(|| {
110            format!(
111                "{path}: unchanged since your read earlier this turn — the full \
112                 content ({line_count} lines) is already in this turn's tool \
113                 results; reuse it. A read after the file changes, or in a later \
114                 turn, returns the full content again."
115            )
116        });
117    }
118    store.push_back(ReadDedupEntry {
119        scope,
120        path: path.to_string(),
121        turn: ctx.turn.0,
122        hash,
123        line_count,
124    });
125    if store.len() > READ_DEDUP_CAP {
126        store.pop_front();
127    }
128    None
129}
130
131/// `read_file` — read one or more files and return their contents
132/// joined with section markers.
133pub struct ReadFileTool;
134
135#[async_trait]
136impl ToolExecutor for ReadFileTool {
137    fn name(&self) -> &'static str {
138        "read_file"
139    }
140
141    fn schema(&self) -> ToolDefinition {
142        defn(
143            "read_file",
144            "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory, the session scratchpad, or the memory directories, or the call is rejected.",
145            serde_json::json!({
146                "type": "object",
147                "properties": {
148                    "path": { "type": "string", "description": "File to read (single)." },
149                    "paths": {
150                        "type": "array",
151                        "items": { "type": "string" },
152                        "description": "Multiple files to read sequentially, in order."
153                    }
154                },
155                "oneOf": [
156                    { "required": ["path"] },
157                    { "required": ["paths"] }
158                ]
159            }),
160        )
161    }
162
163    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
164        let paths = match extract_paths(&args) {
165            Ok(p) => p,
166            Err(e) => return ToolOutcome::error(e, 0.0),
167        };
168        if paths.is_empty() {
169            return ToolOutcome::error("read_file requires at least one path", 0.0);
170        }
171
172        let start = std::time::Instant::now();
173        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
174        let mut combined = String::new();
175        let mut any_truncated = false;
176
177        for (idx, raw_path) in paths.iter().enumerate() {
178            // Race the file read against the turn's cancel token. If
179            // the user Ctrl+C's mid-read, we bail immediately.
180            tokio::select! {
181                biased;
182                _ = ctx.token.cancelled() => {
183                    return ToolOutcome::cancelled();
184                },
185                read = read_one(&roots, raw_path) => {
186                    match read {
187                        Ok((content, was_truncated)) => {
188                            // A byte-identical repeat of a read this same turn
189                            // collapses to a short reuse note — the earlier
190                            // result is still in the model's request. The note
191                            // is not a truncation: nothing was cut that isn't
192                            // already present in full.
193                            let (content, was_truncated) =
194                                duplicate_read_note(&ctx, raw_path, &content)
195                                    .map_or((content, was_truncated), |note| (note, false));
196                            any_truncated |= was_truncated;
197                            if paths.len() > 1 {
198                                let _ = ctx.progress.send(ProgressEvent::Status(
199                                    format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
200                                )).await;
201                                combined.push_str(&format!(
202                                    "=== {raw_path} ===\n{content}\n\n"
203                                ));
204                            } else {
205                                combined = content;
206                            }
207                        },
208                        Err(e) => {
209                            return ToolOutcome::error(
210                                format!("{raw_path}: {e}"),
211                                start.elapsed().as_secs_f64(),
212                            );
213                        },
214                    }
215                },
216            }
217        }
218
219        // F45: bound the COMBINED multi-file result. Each file is already capped
220        // at MAX_RESPONSE_CHARS by read_one, but a batch of files can still sum to
221        // ~12.8 MB in one tool result — past any sane context budget. Only the
222        // multi-file accumulation needs this (single-file output is already
223        // bounded); truncate_middle keeps the head AND tail with an elision marker.
224        if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
225            combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
226            any_truncated = true;
227        }
228
229        let duration_secs = start.elapsed().as_secs_f64();
230        let line_count = combined.lines().count();
231        let byte_count = combined.len();
232        // The REAL truncation flag from the bounded read — not a sniff for the
233        // marker string, which a file containing that literal text would
234        // falsely trip (#78).
235        let truncated = any_truncated;
236        ToolOutcome::success(
237            combined,
238            format!(
239                "{} {} read",
240                line_count,
241                plural(line_count, "line", "lines")
242            ),
243            duration_secs,
244        )
245        .with_metadata(ToolRunMetadata {
246            detail: ToolMetadata::ReadFile {
247                paths,
248                line_count,
249                byte_count,
250                truncated,
251            },
252            line_count: Some(line_count),
253            byte_count: Some(byte_count),
254            ..ToolRunMetadata::default()
255        })
256    }
257}
258
259/// `delete_file` — unlink a file. Errors on directories (use
260/// `execute_command rm -rf` for those — the model shouldn't be
261/// blowing away directories as a routine op).
262pub struct DeleteFileTool;
263
264#[async_trait]
265impl ToolExecutor for DeleteFileTool {
266    fn name(&self) -> &'static str {
267        "delete_file"
268    }
269
270    fn schema(&self) -> ToolDefinition {
271        defn(
272            "delete_file",
273            "Remove a file from disk. Paths must resolve inside the project directory or the session scratchpad. Fails on directories — use `execute_command rm -rf` for those.",
274            serde_json::json!({
275                "type": "object",
276                "properties": { "path": { "type": "string" } },
277                "required": ["path"]
278            }),
279        )
280    }
281
282    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
283        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
284            return err("delete_file requires 'path'", 0.0);
285        };
286        let start = std::time::Instant::now();
287        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
288        let ResolvedInRoot {
289            abs,
290            rel,
291            root,
292            in_scratchpad,
293        } = match resolve_in_roots(&roots, raw_path) {
294            Ok(r) => r,
295            Err(e) => return err(&format!("delete_file: {e}"), 0.0),
296        };
297        let pending_action = serde_json::json!({
298            "tool": "delete_file",
299            "args": { "path": raw_path },
300            "workdir": ctx.workdir.display().to_string(),
301            "turn_id": ctx.turn.0,
302            "call_id": ctx.call_id.0,
303            "task_id": ctx.task_id.clone(),
304        });
305        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
306            &ctx,
307            "delete_file",
308            raw_path,
309            std::slice::from_ref(&abs),
310            pending_action,
311            in_scratchpad,
312        )
313        .await
314        {
315            return *outcome;
316        }
317        // Serialize writers to this canonical path: sibling tool calls in the same
318        // turn run concurrently, so without this the checkpoint + delete could race
319        // another writer to the same file. Distinct paths still overlap. Raced
320        // against cancellation so a contended lock stays Ctrl+C-responsive.
321        let _write_guard = tokio::select! {
322            biased;
323            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
324            g = super::path_lock::lock_path(&abs) => g,
325        };
326        // Scratchpad files are session-private and ephemeral — never
327        // checkpointed into the project's restore history.
328        if ctx.config.safety.checkpoint_on_mutation
329            && !in_scratchpad
330            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
331                &ctx.workdir,
332                std::slice::from_ref(&abs),
333                Some(serde_json::json!({
334                    "tool": "delete_file",
335                    "path": raw_path,
336                })),
337                ctx.checkpoint_origin(),
338            )
339        {
340            return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
341        }
342        let display = raw_path.to_string();
343
344        tokio::select! {
345            biased;
346            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
347            result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
348                match result {
349                    Ok(Ok(())) => {
350                        let duration_secs = start.elapsed().as_secs_f64();
351                        after_file_mutation(&ctx, "delete_file", &display);
352                        ToolOutcome::success(
353                            format!("Deleted {display}"),
354                            "file deleted",
355                            duration_secs,
356                        )
357                        .with_metadata(ToolRunMetadata {
358                            detail: ToolMetadata::DeleteFile { path: display },
359                            ..ToolRunMetadata::default()
360                        })
361                    },
362                    Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
363                                       start.elapsed().as_secs_f64()),
364                    Err(e) => err(&format!("delete_file join error: {e}"),
365                                   start.elapsed().as_secs_f64()),
366                }
367            }
368        }
369    }
370}
371
372/// `create_directory` — `mkdir -p` semantics.
373pub struct CreateDirectoryTool;
374
375#[async_trait]
376impl ToolExecutor for CreateDirectoryTool {
377    fn name(&self) -> &'static str {
378        "create_directory"
379    }
380
381    fn schema(&self) -> ToolDefinition {
382        defn(
383            "create_directory",
384            "Create a directory (and any missing parents) at the given path, inside the project directory or the session scratchpad.",
385            serde_json::json!({
386                "type": "object",
387                "properties": { "path": { "type": "string" } },
388                "required": ["path"]
389            }),
390        )
391    }
392
393    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
394        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
395            return err("create_directory requires 'path'", 0.0);
396        };
397        let start = std::time::Instant::now();
398        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
399        let ResolvedInRoot {
400            abs,
401            rel,
402            root,
403            in_scratchpad,
404        } = match resolve_in_roots(&roots, raw_path) {
405            Ok(r) => r,
406            Err(e) => return err(&format!("create_directory: {e}"), 0.0),
407        };
408        let pending_action = serde_json::json!({
409            "tool": "create_directory",
410            "args": { "path": raw_path },
411            "workdir": ctx.workdir.display().to_string(),
412            "turn_id": ctx.turn.0,
413            "call_id": ctx.call_id.0,
414            "task_id": ctx.task_id.clone(),
415        });
416        if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
417            &ctx,
418            "create_directory",
419            raw_path,
420            std::slice::from_ref(&abs),
421            pending_action,
422            in_scratchpad,
423        )
424        .await
425        {
426            return *outcome;
427        }
428        // Serialize writers to this canonical path (see delete_file). mkdir -p is
429        // idempotent, but a uniform gate keeps ordering consistent and cheap.
430        let _write_guard = tokio::select! {
431            biased;
432            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
433            g = super::path_lock::lock_path(&abs) => g,
434        };
435        // Scratchpad dirs are session-private and ephemeral — never checkpointed.
436        if ctx.config.safety.checkpoint_on_mutation
437            && !in_scratchpad
438            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
439                &ctx.workdir,
440                std::slice::from_ref(&abs),
441                Some(serde_json::json!({
442                    "tool": "create_directory",
443                    "path": raw_path,
444                })),
445                ctx.checkpoint_origin(),
446            )
447        {
448            return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
449        }
450        let display = raw_path.to_string();
451
452        tokio::select! {
453            biased;
454            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
455            result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
456                match result {
457                    Ok(Ok(())) => {
458                        let duration_secs = start.elapsed().as_secs_f64();
459                        after_file_mutation(&ctx, "create_directory", &display);
460                        ToolOutcome::success(
461                            format!("Created directory {display}"),
462                            "directory created",
463                            duration_secs,
464                        )
465                        .with_metadata(ToolRunMetadata {
466                            detail: ToolMetadata::CreateDirectory { path: display },
467                            ..ToolRunMetadata::default()
468                        })
469                    },
470                    Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
471                                       start.elapsed().as_secs_f64()),
472                    Err(e) => err(&format!("create_directory join error: {e}"),
473                                   start.elapsed().as_secs_f64()),
474                }
475            }
476        }
477    }
478}
479
480/// `write_file` — write a single file, creating parent dirs as needed.
481pub struct WriteFileTool;
482
483#[expect(
484    clippy::too_many_lines,
485    reason = "predates the lint; see .github/baselines/expect_budget.txt"
486)]
487#[async_trait]
488impl ToolExecutor for WriteFileTool {
489    fn name(&self) -> &'static str {
490        "write_file"
491    }
492
493    fn schema(&self) -> ToolDefinition {
494        defn(
495            "write_file",
496            "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths must resolve inside the project directory or the session scratchpad. Prefer `apply_patch` for small targeted changes.",
497            serde_json::json!({
498                "type": "object",
499                "properties": {
500                    "path": { "type": "string" },
501                    "content": { "type": "string" }
502                },
503                "required": ["path", "content"]
504            }),
505        )
506    }
507
508    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
509        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
510            return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
511        };
512        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
513            return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
514        };
515
516        let start = std::time::Instant::now();
517        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
518        // `rel` is the root-relative name for the confined fd write (the actual
519        // byte path).
520        let ResolvedInRoot {
521            abs: abs_path,
522            rel,
523            root,
524            in_scratchpad,
525        } = match resolve_in_roots(&roots, path) {
526            Ok(r) => r,
527            Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
528        };
529        let pending_action = serde_json::json!({
530            "tool": "write_file",
531            "args": { "path": path, "content": content },
532            "workdir": ctx.workdir.display().to_string(),
533            "turn_id": ctx.turn.0,
534            "call_id": ctx.call_id.0,
535            "task_id": ctx.task_id.clone(),
536        });
537        let plan_write = match mutation_policy_outcome(
538            &ctx,
539            "write_file",
540            path,
541            std::slice::from_ref(&abs_path),
542            pending_action,
543            in_scratchpad,
544        )
545        .await
546        {
547            MutationGate::Blocked(outcome) => return *outcome,
548            MutationGate::Proceed { plan_write } => plan_write,
549        };
550        // Serialize writers to this canonical path: two write_file/edit calls to
551        // the same file in one turn run concurrently, so without this the last
552        // atomic rename silently wins (lost update). Distinct paths still overlap.
553        // The owned guard is Send and held across the spawn_blocking below.
554        let _write_guard = tokio::select! {
555            biased;
556            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
557            g = super::path_lock::lock_path(&abs_path) => g,
558        };
559        // Scratchpad files are session-private and ephemeral — never checkpointed.
560        if ctx.config.safety.checkpoint_on_mutation
561            && !in_scratchpad
562            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
563                &ctx.workdir,
564                std::slice::from_ref(&abs_path),
565                Some(serde_json::json!({
566                    "tool": "write_file",
567                    "path": path,
568                })),
569                ctx.checkpoint_origin(),
570            )
571        {
572            return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
573        }
574        let display_path = path.to_string();
575        let line_count = content.lines().count();
576        let byte_count = content.len();
577        let content = content.to_string();
578
579        tokio::select! {
580            biased;
581            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
582            // The prior-content read (for the display diff) now happens INSIDE
583            // this blocking job and BOUNDED (#F44/RC-L) — never a synchronous
584            // unbounded `read_to_string` on the async worker thread.
585            result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
586                match result {
587                    Ok(Ok(write)) => {
588                        let duration_secs = start.elapsed().as_secs_f64();
589                        after_file_mutation(&ctx, "write_file", &display_path);
590                        ToolOutcome::success(
591                            format!("Wrote {} ({} lines)", display_path, write.line_count),
592                            format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
593                            duration_secs,
594                        )
595                        .with_metadata(ToolRunMetadata {
596                            detail: ToolMetadata::WriteFile {
597                                path: display_path,
598                                line_count,
599                                byte_count,
600                                created: Some(write.created),
601                            },
602                            line_count: Some(line_count),
603                            byte_count: Some(byte_count),
604                            display_diff: Some(write.diff.display_diff),
605                            diff_truncated: write.diff.truncated,
606                            lines_added: write.diff.added,
607                            lines_removed: write.diff.removed,
608                            plan_file_written: plan_write,
609                            ..ToolRunMetadata::default()
610                        })
611                    },
612                    Ok(Err(e)) => ToolOutcome::error(
613                        format!("write_file({display_path}): {e}"),
614                        start.elapsed().as_secs_f64(),
615                    ),
616                    Err(e) => ToolOutcome::error(
617                        format!("write_file join error: {e}"),
618                        start.elapsed().as_secs_f64(),
619                    ),
620                }
621            }
622        }
623    }
624}
625
626// ─── helpers ────────────────────────────────────────────────────────
627
628fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
629    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
630    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
631        reject_web_url(p)?;
632        return Ok(vec![p.to_string()]);
633    }
634    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
635        if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
636            return Err(format!(
637                "read_file: too many paths ({}); cap is {} per call — split the request",
638                arr.len(),
639                mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
640            ));
641        }
642        let mut out = Vec::with_capacity(arr.len());
643        for v in arr {
644            let Some(s) = v.as_str() else {
645                return Err("read_file 'paths' must be an array of strings".to_string());
646            };
647            reject_web_url(s)?;
648            out.push(s.to_string());
649        }
650        return Ok(out);
651    }
652    Err("read_file requires 'path' or 'paths'".to_string())
653}
654
655/// `read_file` reads the local filesystem, but models under a web-gated
656/// safety mode were observed pointing it at `https://` URLs and treating the
657/// result as a fetch — and the path-resolution error that came back said
658/// nothing about the actual mistake. Name the mistake and the right tool;
659/// whether `web_fetch` is available is then that tool's own story to tell.
660fn reject_web_url(path: &str) -> Result<(), String> {
661    let head: String = path
662        .trim_start()
663        .chars()
664        .take(8)
665        .collect::<String>()
666        .to_ascii_lowercase();
667    if head.starts_with("http://") || head.starts_with("https://") {
668        return Err(format!(
669            "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
670        ));
671    }
672    Ok(())
673}
674
675/// Read-only carve-out for memory facts. Global and project-private memory
676/// live under the OS data dir — outside both allowed roots — and the memory
677/// index tells the model to `read_file` the fact's path, so reads resolve
678/// against the memory roots too. Absolute paths only, with the same
679/// canonical (symlink-resolving) containment as the scratchpad arm. The
680/// write tools never consult this: memory mutation goes through the `memory`
681/// tool, where the policy gate can see it.
682fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
683    if !Path::new(raw).is_absolute() {
684        return None;
685    }
686    for (root, _scope) in crate::app::memory::memory_roots(workdir) {
687        if let Ok((_abs, true)) = resolve_path_within(&root, raw)
688            && let Ok(rel) = relative_within(&root, raw)
689        {
690            return Some((root, rel));
691        }
692    }
693    None
694}
695
696/// Read one file (bounded). Returns the (possibly marker-footed) text and the
697/// REAL truncation flag from the bounded read, so the caller propagates that
698/// rather than sniffing the output for the marker string — which a file whose
699/// own content contains that literal text would otherwise falsely trip (#78).
700async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
701    // Canonical containment gate — rejects escapes (incl. existing symlinks
702    // that resolve outside the allowed roots) before we touch the file, and
703    // names the root-relative path for the confined fd read, so the bytes come
704    // from the inode the kernel resolved under RESOLVE_BENEATH rather than
705    // whatever a concurrently-swapped symlink now points at (#77).
706    let ResolvedInRoot { rel, root, .. } = match resolve_in_roots(roots, raw) {
707        Ok(resolved) => resolved,
708        Err(msg) => {
709            let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
710                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
711            ResolvedInRoot {
712                abs: root.join(&rel),
713                rel,
714                root,
715                in_scratchpad: false,
716            }
717        },
718    };
719    let result = tokio::task::spawn_blocking(move || {
720        let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
721        // Bounded read: never pull more than the cap (+1 probe byte) into RAM,
722        // so a model pointing `read_file` at a multi-gigabyte file can't OOM the
723        // process — a full read would have slurped the whole thing first (#15).
724        let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
725        let mut s = String::from_utf8_lossy(&data).into_owned();
726        if truncated {
727            // Char-boundary-safe truncation with a marker footer.
728            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
729            s.truncate(cut);
730            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
731        }
732        Ok::<_, std::io::Error>((s, truncated))
733    })
734    .await
735    .map_err(|e| std::io::Error::other(e.to_string()))??;
736    Ok(result)
737}
738
739/// Write `content` to `rel` beneath `root` (the project workdir or the session
740/// scratchpad) through the symlink-confined *atomic* writer, creating parent
741/// dirs the same confined way. The bytes are written to a temp and
742/// `renameat`-swapped over the target, all beneath the directory fd the kernel
743/// resolved under `RESOLVE_BENEATH`: a parent dir swapped for an escaping
744/// symlink can't redirect the write (#77), and a crash/kill/disk-full
745/// mid-write leaves the previous file intact rather than a truncated or
746/// half-written one.
747fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
748    if let Some(parent) = rel.parent()
749        && !parent.as_os_str().is_empty()
750    {
751        mermaid_runtime::create_dir_all_beneath(root, parent)?;
752    }
753    mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
754    Ok(content.lines().count())
755}
756
757struct WriteResult {
758    line_count: usize,
759    created: bool,
760    diff: mermaid_model::diff::DisplayDiff,
761}
762
763/// Write `content` and build the display diff against the prior file in ONE
764/// blocking job (#F44/RC-L). The prior content is read BOUNDED via
765/// [`mermaid_model::utils::read_file_capped`] — overwriting a multi-gigabyte file must
766/// not slurp it into RAM on the async worker just to render a diff. A prior file
767/// larger than the read cap (or otherwise unreadable) is elided from the diff
768/// rather than read whole.
769fn write_with_diff_blocking(
770    root: &Path,
771    abs_path: &Path,
772    rel: &Path,
773    content: &str,
774) -> std::io::Result<WriteResult> {
775    let (old_content, created, elide_diff) =
776        match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
777            Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
778            // Existing file is past the read cap — don't pull it all into RAM.
779            Ok((_, true)) => (String::new(), false, true),
780            // Missing file → a fresh create; the diff shows the whole content added.
781            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
782            // Any other read error: don't fail the write over a diff preview.
783            Err(_) => (String::new(), false, true),
784        };
785    let diff = if elide_diff {
786        mermaid_model::diff::DisplayDiff {
787            display_diff: format!(
788                "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
789            ),
790            added: 0,
791            removed: 0,
792            truncated: true,
793        }
794    } else {
795        mermaid_model::diff::generate_display_diff(&old_content, content)
796    };
797    let line_count = write_one_blocking(root, rel, content)?;
798    Ok(WriteResult {
799        line_count,
800        created,
801        diff,
802    })
803}
804
805/// `scratch_contained` is true when the mutation touches ONLY the session
806/// scratchpad (`ResolvedInRoot::in_scratchpad`). The gate downgrades an
807/// `Ask`/`Classify` on such a mutation to proceed — scratch files are
808/// session-private and ephemeral — while read-only mode and `Deny` overrides
809/// still block it.
810/// Outcome of gating a file mutation. `Proceed` carries `plan_write`: whether
811/// the allowance came from plan mode's plan-file carve-out, which the caller
812/// stamps onto `ToolRunMetadata::plan_file_written`.
813///
814/// The tool NAME is not a usable stand-in for this. "Under the plan floor the
815/// only Edit that can succeed is the plan file" stops being true as soon as
816/// `[plan] memory = allow` lets a `write_file` to a memory path succeed.
817pub(super) enum MutationGate {
818    /// Blocked — return this outcome verbatim. Boxed to keep the enum small.
819    Blocked(Box<ToolOutcome>),
820    Proceed {
821        plan_write: bool,
822    },
823}
824
825pub(super) async fn mutation_policy_outcome(
826    ctx: &ExecContext,
827    tool: &str,
828    path: &str,
829    checkpoint_paths: &[PathBuf],
830    pending_action: serde_json::Value,
831    scratch_contained: bool,
832) -> MutationGate {
833    let mut request = mermaid_runtime::ActionRequest::new(
834        tool,
835        mermaid_runtime::ToolCategory::Edit,
836        format!("{tool} {path}"),
837    );
838    request.path = Some(path.to_string());
839    // File mutations are replayable: an Ask decision checkpoints, records an
840    // approval, and blocks (handled inside the gate).
841    match super::policy_gate::gate(
842        ctx,
843        request,
844        checkpoint_paths,
845        pending_action,
846        true,
847        scratch_contained,
848    )
849    .await
850    {
851        super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
852        super::policy_gate::Gate::Proceed { plan_write, .. } => {
853            let _ = mermaid_runtime::run_plugin_hooks(
854                "before_file_mutation",
855                &serde_json::json!({
856                    "task_id": ctx.task_id.clone(),
857                    "turn_id": ctx.turn.0,
858                    "call_id": ctx.call_id.0,
859                    "tool": tool,
860                    "path": path,
861                }),
862            );
863            MutationGate::Proceed { plan_write }
864        },
865    }
866}
867
868pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
869    let _ = mermaid_runtime::run_plugin_hooks(
870        "after_file_mutation",
871        &serde_json::json!({
872            "task_id": ctx.task_id.clone(),
873            "turn_id": ctx.turn.0,
874            "call_id": ctx.call_id.0,
875            "tool": tool,
876            "path": path,
877        }),
878    );
879}
880
881fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
882    ToolOutcome::error(msg, duration_secs)
883}
884
885fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
886    if count == 1 { singular } else { plural }
887}
888
889pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
890    format!(
891        "+{} -{}, took {}",
892        added,
893        removed,
894        format_duration_for_diff(duration_secs)
895    )
896}
897
898fn format_duration_for_diff(seconds: f64) -> String {
899    if seconds < 1.0 {
900        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
901    } else if seconds < 10.0 {
902        format!("{seconds:.1}s")
903    } else {
904        format!("{}s", seconds.round() as u64)
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use crate::providers::ctx::test_exec_context;
912    use mermaid_domain::{ToolCallId, TurnId};
913    use std::fs;
914
915    /// Memory facts live outside the project/scratchpad roots and the index
916    /// tells the model to `read_file` them — reads must resolve against the
917    /// memory roots (here the `ProjectShared` root, reached by putting the
918    /// workdir in a subdir of the git root), while unrelated outside paths
919    /// stay rejected.
920    #[tokio::test]
921    async fn read_file_resolves_memory_roots_read_only() {
922        let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
923        let _ = fs::remove_dir_all(&base);
924        let repo = base.join("repo");
925        let workdir = repo.join("src");
926        fs::create_dir_all(&workdir).unwrap();
927        fs::create_dir_all(repo.join(".git")).unwrap();
928        let mem_dir = repo.join(".mermaid").join("memory");
929        fs::create_dir_all(&mem_dir).unwrap();
930        let fact = mem_dir.join("fact.md");
931        fs::write(&fact, "the fact body").unwrap();
932
933        let roots = AllowedRoots::new(&workdir, None);
934        let (content, truncated) = read_one(&roots, fact.to_str().unwrap()).await.unwrap();
935        assert!(!truncated);
936        assert_eq!(content, "the fact body");
937
938        // Still confined: an outside path that is NOT under a memory root
939        // rejects exactly as before.
940        let stray = base.join("stray.txt");
941        fs::write(&stray, "nope").unwrap();
942        assert!(read_one(&roots, stray.to_str().unwrap()).await.is_err());
943
944        let _ = fs::remove_dir_all(&base);
945    }
946
947    #[test]
948    fn resolve_in_roots_contains_to_workdir() {
949        let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
950        let _ = fs::remove_dir_all(&root);
951        fs::create_dir_all(root.join("sub")).unwrap();
952        let roots = AllowedRoots::new(&root, None);
953
954        // In-root existing + not-yet-existing targets resolve inside root.
955        assert!(resolve_in_roots(&roots, "sub").is_ok());
956        let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
957        let canon_root = fs::canonicalize(&root).unwrap();
958        assert!(resolved.abs.starts_with(&canon_root));
959
960        // `..` escape and absolute outside are rejected.
961        assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
962        assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
963        let outside = std::env::temp_dir().join("definitely_outside.txt");
964        assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
965
966        let _ = fs::remove_dir_all(&root);
967    }
968
969    fn temp_root(name: &str) -> PathBuf {
970        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
971        let _ = fs::remove_dir_all(&p);
972        fs::create_dir_all(&p).expect("create tmpdir");
973        p
974    }
975
976    #[tokio::test]
977    async fn read_file_returns_content() {
978        let dir = temp_root("read_ok");
979        fs::write(dir.join("a.txt"), "hello").expect("write");
980        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
981
982        let tool = ReadFileTool;
983        let outcome = tool
984            .execute(serde_json::json!({"path": "a.txt"}), ctx)
985            .await;
986        assert!(outcome.is_success(), "expected success: {outcome:?}");
987        assert_eq!(outcome.output(), "hello");
988        let _ = fs::remove_dir_all(&dir);
989    }
990
991    #[tokio::test]
992    async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
993        // Observed in the field: a model under a web-gated safety mode fed
994        // `read_file` an https:// URL and treated the reply as a fetch. The
995        // rejection must name the right tool — and a plain local read next to
996        // it must keep working (the guard cannot overmatch).
997        let dir = temp_root("read_url");
998        fs::write(dir.join("a.txt"), "hello").expect("write");
999        for args in [
1000            serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1001            serde_json::json!({"path": "HTTP://example.com/x"}),
1002            serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1003        ] {
1004            let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1005            let outcome = ReadFileTool.execute(args, ctx).await;
1006            assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1007            let msg = outcome.error_message().unwrap_or_default();
1008            assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1009        }
1010        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1011        let outcome = ReadFileTool
1012            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1013            .await;
1014        assert!(outcome.is_success(), "plain local reads must still work");
1015        let _ = fs::remove_dir_all(&dir);
1016    }
1017
1018    #[tokio::test]
1019    async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1020        let dir = temp_root("read_dedup");
1021        fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1022
1023        // First read: full content.
1024        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1025        let outcome = ReadFileTool
1026            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1027            .await;
1028        assert_eq!(outcome.output(), "line one\nline two");
1029
1030        // Byte-identical repeat in the SAME turn: a short reuse note, not
1031        // the body again — the earlier result rides the same request.
1032        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1033        let outcome = ReadFileTool
1034            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1035            .await;
1036        assert!(outcome.is_success());
1037        assert!(
1038            outcome
1039                .output()
1040                .contains("unchanged since your read earlier this turn"),
1041            "{}",
1042            outcome.output()
1043        );
1044        assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1045        assert!(
1046            !outcome.output().contains("line two"),
1047            "the body must not repeat: {}",
1048            outcome.output()
1049        );
1050
1051        // Matched pair (a): the file CHANGED on disk — by any writer; here a
1052        // direct fs write stands in for write_file / apply_patch /
1053        // execute_command — so the same-turn re-read returns full content.
1054        // Content-hash equality is the invalidation; there is no hook to
1055        // forget.
1056        fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1057        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1058        let outcome = ReadFileTool
1059            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1060            .await;
1061        assert_eq!(
1062            outcome.output(),
1063            "line one\nline two\nline three",
1064            "a changed file must read in full"
1065        );
1066
1067        // ...and the byte-identical repeat of THAT read collapses again.
1068        let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1069        let outcome = ReadFileTool
1070            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1071            .await;
1072        assert!(
1073            outcome.output().contains("unchanged since"),
1074            "{}",
1075            outcome.output()
1076        );
1077
1078        // Matched pair (b): a LATER turn always reads in full — a cross-turn
1079        // re-read may be a legitimate refresh (post-compaction, post-edit)
1080        // and is never suppressed.
1081        let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1082        let outcome = ReadFileTool
1083            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1084            .await;
1085        assert_eq!(outcome.output(), "line one\nline two\nline three");
1086
1087        let _ = fs::remove_dir_all(&dir);
1088    }
1089
1090    #[tokio::test]
1091    async fn read_file_missing_path_errors() {
1092        let dir = temp_root("read_missing_path");
1093        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1094        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1095        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1096        let _ = fs::remove_dir_all(&dir);
1097    }
1098
1099    #[tokio::test]
1100    async fn read_file_nonexistent_errors() {
1101        let dir = temp_root("read_nonex");
1102        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1103        let outcome = ReadFileTool
1104            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1105            .await;
1106        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1107        let _ = fs::remove_dir_all(&dir);
1108    }
1109
1110    #[tokio::test]
1111    async fn read_file_with_multiple_paths_joins_contents() {
1112        let dir = temp_root("read_multi");
1113        fs::write(dir.join("a.txt"), "alpha").expect("write");
1114        fs::write(dir.join("b.txt"), "beta").expect("write");
1115        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1116        let outcome = ReadFileTool
1117            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1118            .await;
1119        assert!(outcome.is_success(), "expected success: {outcome:?}");
1120        let output = outcome.output();
1121        assert!(output.contains("=== a.txt ==="));
1122        assert!(output.contains("alpha"));
1123        assert!(output.contains("=== b.txt ==="));
1124        assert!(output.contains("beta"));
1125        let _ = fs::remove_dir_all(&dir);
1126    }
1127
1128    #[tokio::test]
1129    async fn read_file_multi_aggregate_is_capped() {
1130        // F45: many files in one call can't blow past the aggregate cap. Each
1131        // file is under the per-file cap, but their sum exceeds the aggregate.
1132        let dir = temp_root("read_aggregate_cap");
1133        let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1134        fs::write(dir.join("a.txt"), &chunk).expect("write a");
1135        fs::write(dir.join("b.txt"), &chunk).expect("write b");
1136        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1137        let outcome = ReadFileTool
1138            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1139            .await;
1140        assert!(outcome.is_success(), "expected success: {outcome:?}");
1141        let output = outcome.output();
1142        assert!(
1143            output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1144            "combined must be capped, got {} bytes",
1145            output.len()
1146        );
1147        assert!(
1148            output.contains("elided"),
1149            "expected aggregate head+tail elision marker"
1150        );
1151        match &outcome.metadata.detail {
1152            ToolMetadata::ReadFile { truncated, .. } => {
1153                assert!(*truncated, "aggregate truncation must set truncated")
1154            },
1155            other => panic!("expected ReadFile metadata, got {other:?}"),
1156        }
1157        let _ = fs::remove_dir_all(&dir);
1158    }
1159
1160    #[tokio::test]
1161    async fn write_file_elides_diff_for_oversized_existing_file() {
1162        // F44: overwriting a file larger than the read cap must NOT slurp it into
1163        // RAM for a diff — the diff is elided with a marker instead.
1164        let dir = temp_root("write_oversized_diff");
1165        let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1166        fs::write(dir.join("big.txt"), &big).expect("write fixture");
1167        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1168        let outcome = WriteFileTool
1169            .execute(
1170                serde_json::json!({"path": "big.txt", "content": "small\n"}),
1171                ctx,
1172            )
1173            .await;
1174        assert!(outcome.is_success(), "expected success: {outcome:?}");
1175        let diff = outcome
1176            .metadata
1177            .display_diff
1178            .as_deref()
1179            .expect("display diff");
1180        assert!(
1181            diff.contains("diff preview skipped"),
1182            "expected elision marker, got: {diff}"
1183        );
1184        assert!(
1185            outcome.metadata.diff_truncated,
1186            "oversized diff must set diff_truncated"
1187        );
1188        match &outcome.metadata.detail {
1189            ToolMetadata::WriteFile { created, .. } => {
1190                assert_eq!(*created, Some(false), "existing file is not 'created'")
1191            },
1192            other => panic!("expected WriteFile metadata, got {other:?}"),
1193        }
1194        // The file was actually overwritten despite the elided diff.
1195        let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1196        assert_eq!(written, "small\n");
1197        let _ = fs::remove_dir_all(&dir);
1198    }
1199
1200    #[tokio::test]
1201    async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1202        // #78: a small file whose own content contains the truncation-marker
1203        // string must NOT be reported as truncated — the flag comes from the
1204        // bounded read now, not a substring sniff of the output.
1205        let dir = temp_root("read_marker_content");
1206        fs::write(
1207            dir.join("a.txt"),
1208            "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1209        )
1210        .expect("write");
1211        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1212
1213        let outcome = ReadFileTool
1214            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1215            .await;
1216        assert!(outcome.is_success(), "expected success: {outcome:?}");
1217        match &outcome.metadata.detail {
1218            ToolMetadata::ReadFile { truncated, .. } => assert!(
1219                !truncated,
1220                "a file whose content contains the marker must not be flagged truncated"
1221            ),
1222            other => panic!("expected ReadFile metadata, got {other:?}"),
1223        }
1224        let _ = fs::remove_dir_all(&dir);
1225    }
1226
1227    #[tokio::test]
1228    async fn read_file_respects_cancellation() {
1229        let dir = temp_root("read_cancel");
1230        // Write a huge file so the read is slow enough to race cancel.
1231        // Actually spawn_blocking on read is fast on tmpfs — this test
1232        // just verifies the select! arm compiles + the token trips
1233        // the cancel path when pre-cancelled.
1234        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1235        ctx.token.cancel();
1236        let outcome = ReadFileTool
1237            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1238            .await;
1239        assert!(outcome.was_cancelled());
1240        let _ = fs::remove_dir_all(&dir);
1241    }
1242
1243    #[tokio::test]
1244    async fn write_file_creates_and_counts_lines() {
1245        let dir = temp_root("write_ok");
1246        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1247        let outcome = WriteFileTool
1248            .execute(
1249                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1250                ctx,
1251            )
1252            .await;
1253        assert!(outcome.is_success(), "expected success: {outcome:?}");
1254        assert!(outcome.output().contains("3 lines"));
1255        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1256        assert!(written.contains("line1"));
1257        let _ = fs::remove_dir_all(&dir);
1258    }
1259
1260    #[tokio::test]
1261    async fn concurrent_write_file_same_path_serializes_cleanly() {
1262        // The per-path write gate must let two writes to the same file in one turn
1263        // both succeed and leave the file as exactly one clean write (never a
1264        // corrupt interleave), and must not deadlock.
1265        let dir = temp_root("write_race");
1266        let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1267        let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1268        let a = "AAAA\nAAAA\n";
1269        let b = "BBBB\nBBBB\n";
1270        let (o1, o2) = tokio::join!(
1271            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1272            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1273        );
1274        assert!(o1.is_success(), "first write failed: {o1:?}");
1275        assert!(o2.is_success(), "second write failed: {o2:?}");
1276        let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1277        assert!(
1278            final_content == a || final_content == b,
1279            "file must be exactly one clean write, got {final_content:?}"
1280        );
1281        let _ = fs::remove_dir_all(&dir);
1282    }
1283
1284    #[tokio::test]
1285    async fn write_file_new_file_records_added_display_diff() {
1286        let dir = temp_root("write_new_diff");
1287        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1288        let outcome = WriteFileTool
1289            .execute(
1290                serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1291                ctx,
1292            )
1293            .await;
1294        assert!(outcome.is_success(), "expected success: {outcome:?}");
1295        let diff = outcome
1296            .metadata
1297            .display_diff
1298            .as_deref()
1299            .expect("display diff");
1300        assert!(diff.contains("+ alpha"));
1301        assert!(diff.contains("+ beta"));
1302        // No unified-diff header clutter (`---`/`+++`/`@@`).
1303        assert!(
1304            !diff.contains("@@"),
1305            "diff should not carry hunk headers: {diff}"
1306        );
1307        assert!(!diff.contains("/dev/null"));
1308        let _ = fs::remove_dir_all(&dir);
1309    }
1310
1311    #[tokio::test]
1312    async fn write_file_existing_file_records_added_and_removed_display_diff() {
1313        let dir = temp_root("write_existing_diff");
1314        fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1315        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1316        let outcome = WriteFileTool
1317            .execute(
1318                serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1319                ctx,
1320            )
1321            .await;
1322        assert!(outcome.is_success(), "expected success: {outcome:?}");
1323        let diff = outcome
1324            .metadata
1325            .display_diff
1326            .as_deref()
1327            .expect("display diff");
1328        assert!(diff.contains("- old"));
1329        assert!(diff.contains("+ new"));
1330        let _ = fs::remove_dir_all(&dir);
1331    }
1332
1333    #[tokio::test]
1334    async fn write_file_creates_parent_dirs() {
1335        let dir = temp_root("write_parents");
1336        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1337        let outcome = WriteFileTool
1338            .execute(
1339                serde_json::json!({
1340                    "path": "sub/nested/out.txt",
1341                    "content": "deep",
1342                }),
1343                ctx,
1344            )
1345            .await;
1346        assert!(outcome.is_success(), "expected success: {outcome:?}");
1347        assert!(dir.join("sub/nested/out.txt").exists());
1348        let _ = fs::remove_dir_all(&dir);
1349    }
1350
1351    #[tokio::test]
1352    async fn write_file_missing_content_errors() {
1353        let dir = temp_root("write_missing");
1354        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1355        let outcome = WriteFileTool
1356            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1357            .await;
1358        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1359        let _ = fs::remove_dir_all(&dir);
1360    }
1361
1362    // ─── F10: absolute-path block ───────────────────────────────────
1363
1364    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1365    /// must fail with a clear "outside the project" error. The tool
1366    /// schema advertises this contract; before F10 it was a lie.
1367    #[tokio::test]
1368    async fn read_file_rejects_absolute_path_outside_workdir() {
1369        let dir = temp_root("read_abs_escape");
1370        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1371        // Pick a path that's definitely outside a fresh /tmp/* workdir.
1372        let outcome = ReadFileTool
1373            .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1374            .await;
1375        let error = outcome.error_message().expect("expected error");
1376        assert!(
1377            error.contains("outside the project"),
1378            "expected security reject, got: {error}"
1379        );
1380        let _ = fs::remove_dir_all(&dir);
1381    }
1382
1383    /// Absolute path that lives INSIDE the workdir is allowed.
1384    #[tokio::test]
1385    async fn read_file_accepts_absolute_path_inside_workdir() {
1386        let dir = temp_root("read_abs_inside");
1387        let file = dir.join("hello.txt");
1388        fs::write(&file, "ok").expect("write fixture");
1389        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1390        let outcome = ReadFileTool
1391            .execute(
1392                serde_json::json!({"path": file.to_string_lossy().to_string()}),
1393                ctx,
1394            )
1395            .await;
1396        assert!(outcome.is_success(), "expected success: {outcome:?}");
1397        let _ = fs::remove_dir_all(&dir);
1398    }
1399
1400    /// Relative `..`-escape must also be blocked — they resolve against
1401    /// the workdir and land outside it, so the lexical normalization
1402    /// in `resolve_in_roots` catches them.
1403    #[tokio::test]
1404    async fn write_file_rejects_relative_parent_escape() {
1405        let dir = temp_root("write_dotdot_escape");
1406        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1407        let outcome = WriteFileTool
1408            .execute(
1409                serde_json::json!({
1410                    "path": "../escape.txt",
1411                    "content": "should not write",
1412                }),
1413                ctx,
1414            )
1415            .await;
1416        let error = outcome.error_message().expect("expected error");
1417        assert!(
1418            error.contains("outside the project"),
1419            "expected security reject, got: {error}"
1420        );
1421        let _ = fs::remove_dir_all(&dir);
1422    }
1423
1424    /// `create_directory` needs the lexical-normalization fallback
1425    /// because the target doesn't exist yet (can't canonicalize).
1426    /// Verify the escape check still fires for non-existent targets.
1427    #[tokio::test]
1428    async fn create_directory_rejects_absolute_path_outside_workdir() {
1429        let dir = temp_root("mkdir_abs_escape");
1430        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1431        let outcome = CreateDirectoryTool
1432            .execute(
1433                serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1434                ctx,
1435            )
1436            .await;
1437        let error = outcome.error_message().expect("expected error");
1438        assert!(
1439            error.contains("outside the project"),
1440            "expected security reject, got: {error}"
1441        );
1442        let _ = fs::remove_dir_all(&dir);
1443    }
1444
1445    // ─── session-scratchpad dual root ────────────────────────────────
1446
1447    /// Build an `ExecContext` with an explicit safety mode, NO approval
1448    /// broker, and (optionally) a materialized scratchpad. Unlike
1449    /// `test_exec_context` (pinned to `FullAccess`) this exercises the gate.
1450    fn scratch_ctx(
1451        mode: mermaid_runtime::SafetyMode,
1452        workdir: PathBuf,
1453        scratchpad: Option<PathBuf>,
1454    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1455        let mut config = mermaid_domain::Config::default();
1456        config.safety.mode = mode;
1457        let (tx, rx) = tokio::sync::mpsc::channel(8);
1458        let mut ctx = ExecContext::new(
1459            tokio_util::sync::CancellationToken::new(),
1460            tx,
1461            ToolCallId(1),
1462            TurnId(1),
1463            workdir,
1464            std::sync::Arc::new(config),
1465            String::new(),
1466            None,
1467            None,
1468            None,
1469            mode,
1470            None,
1471            None,
1472            None,
1473            None,
1474            None,
1475        );
1476        ctx.scratchpad = scratchpad;
1477        (ctx, rx)
1478    }
1479
1480    /// Project + scratch fixture pair with a unique, greppable name.
1481    fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1482        let base = std::env::temp_dir().join(format!(
1483            "mermaid_fs_scratch_{}_{}",
1484            name,
1485            std::process::id()
1486        ));
1487        let _ = fs::remove_dir_all(&base);
1488        let project = base.join("project");
1489        let scratch = base.join("scratch");
1490        fs::create_dir_all(&project).unwrap();
1491        fs::create_dir_all(&scratch).unwrap();
1492        (project, scratch)
1493    }
1494
1495    /// True when any checkpoint manifest on disk references `marker`. The
1496    /// fixture paths are unique per test+pid, so a hit can only come from
1497    /// the mutation under test.
1498    fn any_checkpoint_mentions(marker: &str) -> bool {
1499        let Ok(data) = mermaid_runtime::data_dir() else {
1500            return false;
1501        };
1502        let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1503            return false;
1504        };
1505        entries.flatten().any(|entry| {
1506            fs::read_to_string(entry.path().join("manifest.json"))
1507                .is_ok_and(|manifest| manifest.contains(marker))
1508        })
1509    }
1510
1511    /// Scratchpad mutations proceed in Ask mode with NO approval broker
1512    /// bound (the gate is bypassed entirely) and never take a checkpoint.
1513    #[tokio::test]
1514    async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1515        let (project, scratch) = scratch_fixture("ungated");
1516        let marker = scratch.display().to_string();
1517
1518        // write_file into the scratchpad via absolute path.
1519        let file = scratch.join("notes.txt");
1520        let (ctx, _rx) = scratch_ctx(
1521            mermaid_runtime::SafetyMode::Ask,
1522            project.clone(),
1523            Some(scratch.clone()),
1524        );
1525        let outcome = WriteFileTool
1526            .execute(
1527                serde_json::json!({
1528                    "path": file.to_str().unwrap(),
1529                    "content": "scratch note\n",
1530                }),
1531                ctx,
1532            )
1533            .await;
1534        assert!(outcome.is_success(), "scratch write: {outcome:?}");
1535        assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1536
1537        // create_directory inside the scratchpad.
1538        let subdir = scratch.join("work/area");
1539        let (ctx, _rx) = scratch_ctx(
1540            mermaid_runtime::SafetyMode::Ask,
1541            project.clone(),
1542            Some(scratch.clone()),
1543        );
1544        let outcome = CreateDirectoryTool
1545            .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1546            .await;
1547        assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1548        assert!(subdir.is_dir());
1549
1550        // delete_file inside the scratchpad.
1551        let (ctx, _rx) = scratch_ctx(
1552            mermaid_runtime::SafetyMode::Ask,
1553            project.clone(),
1554            Some(scratch.clone()),
1555        );
1556        let outcome = DeleteFileTool
1557            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1558            .await;
1559        assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1560        assert!(!file.exists());
1561
1562        // None of the mutations checkpointed the ephemeral scratch paths.
1563        assert!(
1564            !any_checkpoint_mentions(&marker),
1565            "scratch mutation must not create a checkpoint"
1566        );
1567        let _ = fs::remove_dir_all(project.parent().unwrap());
1568    }
1569
1570    /// `ReadOnly` still blocks scratchpad mutations — the bypass only skips
1571    /// the approval flow, never the mode's mutation ban.
1572    #[tokio::test]
1573    async fn scratch_mutation_blocked_in_read_only() {
1574        let (project, scratch) = scratch_fixture("readonly");
1575        let file = scratch.join("blocked.txt");
1576        let (ctx, _rx) = scratch_ctx(
1577            mermaid_runtime::SafetyMode::ReadOnly,
1578            project.clone(),
1579            Some(scratch.clone()),
1580        );
1581        let outcome = WriteFileTool
1582            .execute(
1583                serde_json::json!({
1584                    "path": file.to_str().unwrap(),
1585                    "content": "nope",
1586                }),
1587                ctx,
1588            )
1589            .await;
1590        let error = outcome.error_message().expect("expected block");
1591        assert!(
1592            error.contains("blocked by policy"),
1593            "expected policy block, got: {error}"
1594        );
1595        assert!(!file.exists());
1596        let _ = fs::remove_dir_all(project.parent().unwrap());
1597    }
1598
1599    /// A path outside BOTH roots is rejected at resolution — before the gate.
1600    #[tokio::test]
1601    async fn write_outside_both_roots_is_rejected() {
1602        let (project, scratch) = scratch_fixture("outside");
1603        let outside = project.parent().unwrap().join("elsewhere/out.txt");
1604        let (ctx, _rx) = scratch_ctx(
1605            mermaid_runtime::SafetyMode::Ask,
1606            project.clone(),
1607            Some(scratch.clone()),
1608        );
1609        let outcome = WriteFileTool
1610            .execute(
1611                serde_json::json!({
1612                    "path": outside.to_str().unwrap(),
1613                    "content": "should not write",
1614                }),
1615                ctx,
1616            )
1617            .await;
1618        let error = outcome.error_message().expect("expected error");
1619        assert!(
1620            error.contains("outside the project"),
1621            "expected containment reject, got: {error}"
1622        );
1623        assert!(!outside.exists());
1624        let _ = fs::remove_dir_all(project.parent().unwrap());
1625    }
1626
1627    /// `read_file` follows a materialized scratchpad too.
1628    #[tokio::test]
1629    async fn read_file_reads_from_scratchpad() {
1630        let (project, scratch) = scratch_fixture("read");
1631        let file = scratch.join("stash.txt");
1632        fs::write(&file, "stashed").unwrap();
1633        let (ctx, _rx) = scratch_ctx(
1634            mermaid_runtime::SafetyMode::Ask,
1635            project.clone(),
1636            Some(scratch.clone()),
1637        );
1638        let outcome = ReadFileTool
1639            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1640            .await;
1641        assert!(outcome.is_success(), "scratch read: {outcome:?}");
1642        assert_eq!(outcome.output(), "stashed");
1643        let _ = fs::remove_dir_all(project.parent().unwrap());
1644    }
1645}