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