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