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