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;
24
25/// Small helper for building a `ToolDefinition` with a typical
26/// JSON-schema-shaped input_schema. Keeps the per-tool definitions
27/// readable.
28fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
29    ToolDefinition {
30        name: name.to_string(),
31        description: description.to_string(),
32        input_schema,
33    }
34}
35
36/// `read_file` — read one or more files and return their contents
37/// joined with section markers.
38pub struct ReadFileTool;
39
40#[async_trait]
41impl ToolExecutor for ReadFileTool {
42    fn name(&self) -> &'static str {
43        "read_file"
44    }
45
46    fn schema(&self) -> ToolDefinition {
47        defn(
48            "read_file",
49            "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.",
50            serde_json::json!({
51                "type": "object",
52                "properties": {
53                    "path": { "type": "string", "description": "File to read (single)." },
54                    "paths": {
55                        "type": "array",
56                        "items": { "type": "string" },
57                        "description": "Multiple files to read in parallel."
58                    }
59                },
60                "oneOf": [
61                    { "required": ["path"] },
62                    { "required": ["paths"] }
63                ]
64            }),
65        )
66    }
67
68    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
69        let paths = match extract_paths(&args) {
70            Ok(p) => p,
71            Err(e) => return ToolOutcome::error(e, 0.0),
72        };
73        if paths.is_empty() {
74            return ToolOutcome::error("read_file requires at least one path", 0.0);
75        }
76
77        let start = std::time::Instant::now();
78        let workdir = ctx.workdir.clone();
79        let mut combined = String::new();
80
81        for (idx, raw_path) in paths.iter().enumerate() {
82            // Race the file read against the turn's cancel token. If
83            // the user Ctrl+C's mid-read, we bail immediately.
84            tokio::select! {
85                biased;
86                _ = ctx.token.cancelled() => {
87                    return ToolOutcome::cancelled();
88                },
89                read = read_one(&workdir, raw_path) => {
90                    match read {
91                        Ok(content) => {
92                            if paths.len() > 1 {
93                                let _ = ctx.progress.send(ProgressEvent::Status(
94                                    format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
95                                )).await;
96                                combined.push_str(&format!(
97                                    "=== {} ===\n{}\n\n",
98                                    raw_path, content
99                                ));
100                            } else {
101                                combined = content;
102                            }
103                        },
104                        Err(e) => {
105                            return ToolOutcome::error(
106                                format!("{}: {}", raw_path, e),
107                                start.elapsed().as_secs_f64(),
108                            );
109                        },
110                    }
111                },
112            }
113        }
114
115        let duration_secs = start.elapsed().as_secs_f64();
116        let line_count = combined.lines().count();
117        let byte_count = combined.len();
118        let truncated = combined.contains("[TRUNCATED: file exceeded read cap]");
119        ToolOutcome::success(
120            combined,
121            format!(
122                "{} {} read",
123                line_count,
124                plural(line_count, "line", "lines")
125            ),
126            duration_secs,
127        )
128        .with_metadata(ToolRunMetadata {
129            detail: ToolMetadata::ReadFile {
130                paths,
131                line_count,
132                byte_count,
133                truncated,
134            },
135            line_count: Some(line_count),
136            byte_count: Some(byte_count),
137            ..ToolRunMetadata::default()
138        })
139    }
140}
141
142/// `edit_file` — exact-match string replacement. Used for targeted
143/// edits rather than full file rewrites. Errors if the `old_string`
144/// doesn't appear exactly once.
145pub struct EditFileTool;
146
147#[async_trait]
148impl ToolExecutor for EditFileTool {
149    fn name(&self) -> &'static str {
150        "edit_file"
151    }
152
153    fn schema(&self) -> ToolDefinition {
154        defn(
155            "edit_file",
156            "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.",
157            serde_json::json!({
158                "type": "object",
159                "properties": {
160                    "path": { "type": "string" },
161                    "old_string": { "type": "string", "description": "Exact text to replace. Must appear exactly once." },
162                    "new_string": { "type": "string", "description": "Replacement text." }
163                },
164                "required": ["path", "old_string", "new_string"]
165            }),
166        )
167    }
168
169    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
170        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
171            return err("edit_file requires 'path'", 0.0);
172        };
173        let Some(old_string) = args.get("old_string").and_then(|v| v.as_str()) else {
174            return err("edit_file requires 'old_string'", 0.0);
175        };
176        let Some(new_string) = args.get("new_string").and_then(|v| v.as_str()) else {
177            return err("edit_file requires 'new_string'", 0.0);
178        };
179
180        let start = std::time::Instant::now();
181        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
182            Ok(p) => p,
183            Err(e) => return err(&format!("edit_file: {}", e), 0.0),
184        };
185        let old_owned = old_string.to_string();
186        let new_owned = new_string.to_string();
187        let abs_clone = abs.clone();
188        let display_path = raw_path.to_string();
189
190        tokio::select! {
191            biased;
192            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
193            result = tokio::task::spawn_blocking(move || edit_blocking(&abs_clone, &old_owned, &new_owned)) => {
194                match result {
195                    Ok(Ok(replacements)) => {
196                        let duration_secs = start.elapsed().as_secs_f64();
197                        ToolOutcome::success(
198                            format!("Edited {} ({} replacement{})",
199                            display_path,
200                            replacements,
201                            if replacements == 1 { "" } else { "s" }),
202                            format!("{} replacement{}", replacements, if replacements == 1 { "" } else { "s" }),
203                            duration_secs,
204                        )
205                        .with_metadata(ToolRunMetadata {
206                            detail: ToolMetadata::EditFile {
207                                path: display_path,
208                                replacements,
209                            },
210                            ..ToolRunMetadata::default()
211                        })
212                    },
213                    Ok(Err(e)) => err(&format!("edit_file({}): {}", display_path, e),
214                                       start.elapsed().as_secs_f64()),
215                    Err(e) => err(&format!("edit_file join error: {}", e),
216                                   start.elapsed().as_secs_f64()),
217                }
218            }
219        }
220    }
221}
222
223/// `delete_file` — unlink a file. Errors on directories (use
224/// `execute_command rm -rf` for those — the model shouldn't be
225/// blowing away directories as a routine op).
226pub struct DeleteFileTool;
227
228#[async_trait]
229impl ToolExecutor for DeleteFileTool {
230    fn name(&self) -> &'static str {
231        "delete_file"
232    }
233
234    fn schema(&self) -> ToolDefinition {
235        defn(
236            "delete_file",
237            "Remove a file from disk. Fails on directories — use `execute_command rm -rf` for those.",
238            serde_json::json!({
239                "type": "object",
240                "properties": { "path": { "type": "string" } },
241                "required": ["path"]
242            }),
243        )
244    }
245
246    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
247        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
248            return err("delete_file requires 'path'", 0.0);
249        };
250        let start = std::time::Instant::now();
251        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
252            Ok(p) => p,
253            Err(e) => return err(&format!("delete_file: {}", e), 0.0),
254        };
255        let display = raw_path.to_string();
256
257        tokio::select! {
258            biased;
259            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
260            result = tokio::task::spawn_blocking(move || std::fs::remove_file(&abs)) => {
261                match result {
262                    Ok(Ok(())) => {
263                        let duration_secs = start.elapsed().as_secs_f64();
264                        ToolOutcome::success(
265                            format!("Deleted {}", display),
266                            "file deleted",
267                            duration_secs,
268                        )
269                        .with_metadata(ToolRunMetadata {
270                            detail: ToolMetadata::DeleteFile { path: display },
271                            ..ToolRunMetadata::default()
272                        })
273                    },
274                    Ok(Err(e)) => err(&format!("delete_file({}): {}", display, e),
275                                       start.elapsed().as_secs_f64()),
276                    Err(e) => err(&format!("delete_file join error: {}", e),
277                                   start.elapsed().as_secs_f64()),
278                }
279            }
280        }
281    }
282}
283
284/// `create_directory` — `mkdir -p` semantics.
285pub struct CreateDirectoryTool;
286
287#[async_trait]
288impl ToolExecutor for CreateDirectoryTool {
289    fn name(&self) -> &'static str {
290        "create_directory"
291    }
292
293    fn schema(&self) -> ToolDefinition {
294        defn(
295            "create_directory",
296            "Create a directory (and any missing parents) at the given path.",
297            serde_json::json!({
298                "type": "object",
299                "properties": { "path": { "type": "string" } },
300                "required": ["path"]
301            }),
302        )
303    }
304
305    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
306        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
307            return err("create_directory requires 'path'", 0.0);
308        };
309        let start = std::time::Instant::now();
310        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
311            Ok(p) => p,
312            Err(e) => return err(&format!("create_directory: {}", e), 0.0),
313        };
314        let display = raw_path.to_string();
315
316        tokio::select! {
317            biased;
318            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
319            result = tokio::task::spawn_blocking(move || std::fs::create_dir_all(&abs)) => {
320                match result {
321                    Ok(Ok(())) => {
322                        let duration_secs = start.elapsed().as_secs_f64();
323                        ToolOutcome::success(
324                            format!("Created directory {}", display),
325                            "directory created",
326                            duration_secs,
327                        )
328                        .with_metadata(ToolRunMetadata {
329                            detail: ToolMetadata::CreateDirectory { path: display },
330                            ..ToolRunMetadata::default()
331                        })
332                    },
333                    Ok(Err(e)) => err(&format!("create_directory({}): {}", display, e),
334                                       start.elapsed().as_secs_f64()),
335                    Err(e) => err(&format!("create_directory join error: {}", e),
336                                   start.elapsed().as_secs_f64()),
337                }
338            }
339        }
340    }
341}
342
343/// `write_file` — write a single file, creating parent dirs as needed.
344pub struct WriteFileTool;
345
346#[async_trait]
347impl ToolExecutor for WriteFileTool {
348    fn name(&self) -> &'static str {
349        "write_file"
350    }
351
352    fn schema(&self) -> ToolDefinition {
353        defn(
354            "write_file",
355            "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Prefer `edit_file` for small targeted changes.",
356            serde_json::json!({
357                "type": "object",
358                "properties": {
359                    "path": { "type": "string" },
360                    "content": { "type": "string" }
361                },
362                "required": ["path", "content"]
363            }),
364        )
365    }
366
367    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
368        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
369            return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
370        };
371        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
372            return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
373        };
374
375        let start = std::time::Instant::now();
376        let abs_path = match resolve_path_safe(&ctx.workdir, path) {
377            Ok(p) => p,
378            Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
379        };
380        let display_path = path.to_string();
381        let line_count = content.lines().count();
382        let byte_count = content.len();
383        let created = Some(!abs_path.exists());
384        let content = content.to_string();
385
386        tokio::select! {
387            biased;
388            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
389            result = tokio::task::spawn_blocking(move || write_one_blocking(&abs_path, &content)) => {
390                match result {
391                    Ok(Ok(actual_line_count)) => {
392                        let duration_secs = start.elapsed().as_secs_f64();
393                        ToolOutcome::success(
394                            format!("Wrote {} ({} lines)", display_path, actual_line_count),
395                            format!("{} {} written", actual_line_count, plural(actual_line_count, "line", "lines")),
396                            duration_secs,
397                        )
398                        .with_metadata(ToolRunMetadata {
399                            detail: ToolMetadata::WriteFile {
400                                path: display_path,
401                                line_count,
402                                byte_count,
403                                created,
404                            },
405                            line_count: Some(line_count),
406                            byte_count: Some(byte_count),
407                            ..ToolRunMetadata::default()
408                        })
409                    },
410                    Ok(Err(e)) => ToolOutcome::error(
411                        format!("write_file({}): {}", display_path, e),
412                        start.elapsed().as_secs_f64(),
413                    ),
414                    Err(e) => ToolOutcome::error(
415                        format!("write_file join error: {}", e),
416                        start.elapsed().as_secs_f64(),
417                    ),
418                }
419            }
420        }
421    }
422}
423
424// ─── helpers ────────────────────────────────────────────────────────
425
426fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
427    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
428    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
429        return Ok(vec![p.to_string()]);
430    }
431    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
432        let mut out = Vec::with_capacity(arr.len());
433        for v in arr {
434            let Some(s) = v.as_str() else {
435                return Err("read_file 'paths' must be an array of strings".to_string());
436            };
437            out.push(s.to_string());
438        }
439        return Ok(out);
440    }
441    Err("read_file requires 'path' or 'paths'".to_string())
442}
443
444/// Resolve a caller-supplied path against `workdir`, enforcing the
445/// "absolute paths outside the project are blocked" contract advertised
446/// in the tool schema.
447///
448/// Rules (F10):
449/// - Relative paths → joined onto `workdir` unchanged. `..` components
450///   are NOT rejected here — a relative `../foo` resolves against the
451///   workdir and then gets the same absolute-path containment check as
452///   an absolute input.
453/// - Absolute paths → canonicalized (resolves `..` + symlinks) and
454///   checked against the canonicalized `workdir`. Escape → `Err`.
455/// - Non-existent paths that won't canonicalize → lexical fallback:
456///   normalize `..` components manually, then compare prefixes. This
457///   matters for `write_file` / `create_directory` where the target
458///   doesn't exist yet.
459fn resolve_path_safe(workdir: &Path, raw: &str) -> Result<PathBuf, String> {
460    let p = PathBuf::from(raw);
461    let candidate = if p.is_absolute() { p } else { workdir.join(p) };
462
463    // Canonicalize the workdir once. If workdir itself can't canonicalize
464    // (shouldn't happen — `cwd` is the running process's cwd), fall back
465    // to the supplied path so the block still applies lexically.
466    let root = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf());
467
468    let canonical =
469        std::fs::canonicalize(&candidate).unwrap_or_else(|_| lexical_normalize(&candidate));
470
471    if canonical.starts_with(&root) {
472        Ok(candidate)
473    } else {
474        Err(format!(
475            "path '{}' is outside the project directory '{}'",
476            raw,
477            workdir.display()
478        ))
479    }
480}
481
482/// Normalize a path lexically (no filesystem access), collapsing `.` and
483/// resolving `..` without symlink expansion. Used when a target doesn't
484/// exist yet (write_file / create_directory) so `canonicalize` would
485/// fail but we still want to reject `..`-escapes.
486fn lexical_normalize(p: &Path) -> PathBuf {
487    use std::path::Component;
488    let mut out = PathBuf::new();
489    for comp in p.components() {
490        match comp {
491            Component::ParentDir => {
492                // Drop the last segment if one exists; otherwise keep
493                // the `..` (can only happen on relative paths, which
494                // the caller has already joined against workdir).
495                if !out.pop() {
496                    out.push("..");
497                }
498            },
499            Component::CurDir => {},
500            other => out.push(other.as_os_str()),
501        }
502    }
503    out
504}
505
506async fn read_one(workdir: &Path, raw: &str) -> std::io::Result<String> {
507    let abs = resolve_path_safe(workdir, raw)
508        .map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
509    let abs_clone = abs.clone();
510    let content = tokio::task::spawn_blocking(move || {
511        let data = std::fs::read(&abs_clone)?;
512        if data.len() > MAX_FILE_READ_BYTES {
513            // Char-boundary-safe truncation with a marker footer.
514            let mut s = String::from_utf8_lossy(&data).into_owned();
515            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
516            s.truncate(cut);
517            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
518            Ok::<_, std::io::Error>(s)
519        } else {
520            Ok(String::from_utf8_lossy(&data).into_owned())
521        }
522    })
523    .await
524    .map_err(|e| std::io::Error::other(e.to_string()))??;
525    let _ = abs;
526    Ok(content)
527}
528
529fn write_one_blocking(path: &Path, content: &str) -> std::io::Result<usize> {
530    if let Some(parent) = path.parent() {
531        std::fs::create_dir_all(parent)?;
532    }
533    std::fs::write(path, content)?;
534    Ok(content.lines().count())
535}
536
537fn edit_blocking(path: &Path, old_string: &str, new_string: &str) -> std::io::Result<usize> {
538    let current = std::fs::read_to_string(path)?;
539    let count = current.matches(old_string).count();
540    if count == 0 {
541        return Err(std::io::Error::other(
542            "old_string not found (is the snippet correct? use read_file to verify)",
543        ));
544    }
545    if count > 1 {
546        return Err(std::io::Error::other(format!(
547            "old_string appears {} times — add more context so the match is unique",
548            count
549        )));
550    }
551    let updated = current.replacen(old_string, new_string, 1);
552    std::fs::write(path, updated)?;
553    Ok(1)
554}
555
556fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
557    ToolOutcome::error(msg, duration_secs)
558}
559
560fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
561    if count == 1 { singular } else { plural }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::domain::{ToolCallId, TurnId};
568    use crate::providers::ctx::test_exec_context;
569    use std::fs;
570
571    fn temp_root(name: &str) -> PathBuf {
572        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
573        let _ = fs::remove_dir_all(&p);
574        fs::create_dir_all(&p).expect("create tmpdir");
575        p
576    }
577
578    #[tokio::test]
579    async fn read_file_returns_content() {
580        let dir = temp_root("read_ok");
581        fs::write(dir.join("a.txt"), "hello").expect("write");
582        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
583
584        let tool = ReadFileTool;
585        let outcome = tool
586            .execute(serde_json::json!({"path": "a.txt"}), ctx)
587            .await;
588        assert!(outcome.is_success(), "expected success: {:?}", outcome);
589        assert_eq!(outcome.output(), "hello");
590        let _ = fs::remove_dir_all(&dir);
591    }
592
593    #[tokio::test]
594    async fn read_file_missing_path_errors() {
595        let dir = temp_root("read_missing_path");
596        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
597        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
598        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
599        let _ = fs::remove_dir_all(&dir);
600    }
601
602    #[tokio::test]
603    async fn read_file_nonexistent_errors() {
604        let dir = temp_root("read_nonex");
605        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
606        let outcome = ReadFileTool
607            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
608            .await;
609        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
610        let _ = fs::remove_dir_all(&dir);
611    }
612
613    #[tokio::test]
614    async fn read_file_with_multiple_paths_joins_contents() {
615        let dir = temp_root("read_multi");
616        fs::write(dir.join("a.txt"), "alpha").expect("write");
617        fs::write(dir.join("b.txt"), "beta").expect("write");
618        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
619        let outcome = ReadFileTool
620            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
621            .await;
622        assert!(outcome.is_success(), "expected success: {:?}", outcome);
623        let output = outcome.output();
624        assert!(output.contains("=== a.txt ==="));
625        assert!(output.contains("alpha"));
626        assert!(output.contains("=== b.txt ==="));
627        assert!(output.contains("beta"));
628        let _ = fs::remove_dir_all(&dir);
629    }
630
631    #[tokio::test]
632    async fn read_file_respects_cancellation() {
633        let dir = temp_root("read_cancel");
634        // Write a huge file so the read is slow enough to race cancel.
635        // Actually spawn_blocking on read is fast on tmpfs — this test
636        // just verifies the select! arm compiles + the token trips
637        // the cancel path when pre-cancelled.
638        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
639        ctx.token.cancel();
640        let outcome = ReadFileTool
641            .execute(serde_json::json!({"path": "x.txt"}), ctx)
642            .await;
643        assert!(outcome.was_cancelled());
644        let _ = fs::remove_dir_all(&dir);
645    }
646
647    #[tokio::test]
648    async fn write_file_creates_and_counts_lines() {
649        let dir = temp_root("write_ok");
650        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
651        let outcome = WriteFileTool
652            .execute(
653                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
654                ctx,
655            )
656            .await;
657        assert!(outcome.is_success(), "expected success: {:?}", outcome);
658        assert!(outcome.output().contains("3 lines"));
659        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
660        assert!(written.contains("line1"));
661        let _ = fs::remove_dir_all(&dir);
662    }
663
664    #[tokio::test]
665    async fn write_file_creates_parent_dirs() {
666        let dir = temp_root("write_parents");
667        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
668        let outcome = WriteFileTool
669            .execute(
670                serde_json::json!({
671                    "path": "sub/nested/out.txt",
672                    "content": "deep",
673                }),
674                ctx,
675            )
676            .await;
677        assert!(outcome.is_success(), "expected success: {:?}", outcome);
678        assert!(dir.join("sub/nested/out.txt").exists());
679        let _ = fs::remove_dir_all(&dir);
680    }
681
682    #[tokio::test]
683    async fn write_file_missing_content_errors() {
684        let dir = temp_root("write_missing");
685        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
686        let outcome = WriteFileTool
687            .execute(serde_json::json!({"path": "x.txt"}), ctx)
688            .await;
689        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
690        let _ = fs::remove_dir_all(&dir);
691    }
692
693    // ─── F10: absolute-path block ───────────────────────────────────
694
695    /// Reading `/etc/passwd` (or any absolute path outside workdir)
696    /// must fail with a clear "outside the project" error. The tool
697    /// schema advertises this contract; before F10 it was a lie.
698    #[tokio::test]
699    async fn read_file_rejects_absolute_path_outside_workdir() {
700        let dir = temp_root("read_abs_escape");
701        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
702        // Pick a path that's definitely outside a fresh /tmp/* workdir.
703        let outcome = ReadFileTool
704            .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
705            .await;
706        let error = outcome.error_message().expect("expected error");
707        assert!(
708            error.contains("outside the project"),
709            "expected security reject, got: {}",
710            error
711        );
712        let _ = fs::remove_dir_all(&dir);
713    }
714
715    /// Absolute path that lives INSIDE the workdir is allowed.
716    #[tokio::test]
717    async fn read_file_accepts_absolute_path_inside_workdir() {
718        let dir = temp_root("read_abs_inside");
719        let file = dir.join("hello.txt");
720        fs::write(&file, "ok").expect("write fixture");
721        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
722        let outcome = ReadFileTool
723            .execute(
724                serde_json::json!({"path": file.to_string_lossy().to_string()}),
725                ctx,
726            )
727            .await;
728        assert!(outcome.is_success(), "expected success: {:?}", outcome);
729        let _ = fs::remove_dir_all(&dir);
730    }
731
732    /// Relative `..`-escape must also be blocked — they resolve against
733    /// the workdir and land outside it, so the lexical normalization
734    /// in `resolve_path_safe` catches them.
735    #[tokio::test]
736    async fn write_file_rejects_relative_parent_escape() {
737        let dir = temp_root("write_dotdot_escape");
738        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
739        let outcome = WriteFileTool
740            .execute(
741                serde_json::json!({
742                    "path": "../escape.txt",
743                    "content": "should not write",
744                }),
745                ctx,
746            )
747            .await;
748        let error = outcome.error_message().expect("expected error");
749        assert!(
750            error.contains("outside the project"),
751            "expected security reject, got: {}",
752            error
753        );
754        let _ = fs::remove_dir_all(&dir);
755    }
756
757    /// `create_directory` needs the lexical-normalization fallback
758    /// because the target doesn't exist yet (can't canonicalize).
759    /// Verify the escape check still fires for non-existent targets.
760    #[tokio::test]
761    async fn create_directory_rejects_absolute_path_outside_workdir() {
762        let dir = temp_root("mkdir_abs_escape");
763        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
764        let outcome = CreateDirectoryTool
765            .execute(
766                serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
767                ctx,
768            )
769            .await;
770        let error = outcome.error_message().expect("expected error");
771        assert!(
772            error.contains("outside the project"),
773            "expected security reject, got: {}",
774            error
775        );
776        let _ = fs::remove_dir_all(&dir);
777    }
778}