Skip to main content

agentkit_tool_fs/
lib.rs

1//! Filesystem tools and session-scoped policies for agentkit.
2//!
3//! This crate provides a set of tools that let an agent interact with the
4//! local filesystem: reading, writing, replacing, moving, deleting files,
5//! listing directories, and creating directories. All tools implement the
6//! [`Tool`](agentkit_tools_core::Tool) trait and can be registered with a
7//! [`ToolRegistry`](agentkit_tools_core::ToolRegistry).
8//!
9//! The crate also provides [`FileSystemToolResources`] and
10//! [`FileSystemToolPolicy`] for enforcing session-scoped access rules such
11//! as requiring a path to be read before it can be modified.
12//!
13//! # Quick start
14//!
15//! ```rust
16//! use agentkit_tool_fs::{registry, FileSystemToolPolicy, FileSystemToolResources};
17//!
18//! // Get a registry with all seven filesystem tools.
19//! let reg = registry();
20//! assert_eq!(reg.specs().len(), 7);
21//!
22//! // Optionally configure a policy to guard mutations.
23//! let resources = FileSystemToolResources::new()
24//!     .with_policy(
25//!         FileSystemToolPolicy::new()
26//!             .require_read_before_write(true),
27//!     );
28//! ```
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::time::Instant;
34
35use agentkit_core::{MetadataMap, SessionId, ToolOutput, ToolResultPart};
36use agentkit_tools_core::{
37    FileSystemPermissionRequest, PermissionCode, PermissionDenial, PermissionRequest, Tool,
38    ToolAnnotations, ToolContext, ToolError, ToolName, ToolRegistry, ToolRequest, ToolResources,
39    ToolResult, ToolSpec,
40};
41use async_trait::async_trait;
42use futures_lite::StreamExt;
43use serde::Deserialize;
44use serde_json::{Value, json};
45use thiserror::Error;
46
47/// Creates a [`ToolRegistry`] pre-populated with all filesystem tools.
48///
49/// The returned registry contains [`ReadFileTool`], [`WriteFileTool`],
50/// [`ReplaceInFileTool`], [`MoveTool`], [`DeleteTool`], [`ListDirectoryTool`],
51/// and [`CreateDirectoryTool`], each with default configuration.
52///
53/// # Example
54///
55/// ```rust
56/// use agentkit_tool_fs::registry;
57///
58/// let reg = registry();
59/// let specs = reg.specs();
60/// assert_eq!(specs.len(), 7);
61/// ```
62pub fn registry() -> ToolRegistry {
63    ToolRegistry::new()
64        .with(ReadFileTool::default())
65        .with(WriteFileTool::default())
66        .with(ReplaceInFileTool::default())
67        .with(MoveTool::default())
68        .with(DeleteTool::default())
69        .with(ListDirectoryTool::default())
70        .with(CreateDirectoryTool::default())
71}
72
73/// Errors specific to filesystem tool operations.
74///
75/// These are domain errors that arise from invalid arguments or unsupported
76/// paths rather than I/O failures. They are typically converted into
77/// [`ToolError::InvalidInput`](agentkit_tools_core::ToolError::InvalidInput)
78/// before being returned to the caller.
79#[derive(Debug, Error)]
80pub enum FileSystemToolError {
81    /// The given path cannot be represented as valid UTF-8.
82    #[error("path {0} is not valid UTF-8")]
83    InvalidUtf8Path(PathBuf),
84    /// The requested line range is invalid (e.g. `from` exceeds `to`).
85    #[error("invalid line range: from={from:?} to={to:?}")]
86    InvalidLineRange {
87        /// 1-based inclusive start line, if specified.
88        from: Option<usize>,
89        /// 1-based inclusive end line, if specified.
90        to: Option<usize>,
91    },
92}
93
94/// Policy governing session-scoped filesystem access rules.
95///
96/// Policies are enforced by [`FileSystemToolResources`] on a per-session basis.
97/// The primary policy today is `require_read_before_write`, which prevents an
98/// agent from mutating a path it has not first inspected (via read or list).
99///
100/// # Example
101///
102/// ```rust
103/// use agentkit_tool_fs::FileSystemToolPolicy;
104///
105/// let policy = FileSystemToolPolicy::new()
106///     .require_read_before_write(true);
107/// ```
108#[derive(Clone, Debug, Default)]
109pub struct FileSystemToolPolicy {
110    require_read_before_write: bool,
111}
112
113impl FileSystemToolPolicy {
114    /// Creates a new policy with all rules disabled.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// When enabled, the agent must read (or list) a path before it can write,
120    /// replace, move, or delete it. This helps prevent accidental overwrites.
121    ///
122    /// Defaults to `false`.
123    pub fn require_read_before_write(mut self, value: bool) -> Self {
124        self.require_read_before_write = value;
125        self
126    }
127}
128
129#[derive(Default)]
130struct SessionAccessState {
131    inspected_paths: BTreeSet<PathBuf>,
132}
133
134/// Session-scoped resource state for filesystem tools.
135///
136/// `FileSystemToolResources` implements
137/// [`ToolResources`](agentkit_tools_core::ToolResources) and tracks which paths
138/// each session has inspected. Combined with a [`FileSystemToolPolicy`], it
139/// enforces rules such as requiring a read before a write.
140///
141/// Pass an instance as the `resources` field of
142/// [`ToolContext`](agentkit_tools_core::ToolContext) so that filesystem tools
143/// can record and check access.
144///
145/// # Example
146///
147/// ```rust
148/// use agentkit_tool_fs::{FileSystemToolPolicy, FileSystemToolResources};
149///
150/// let resources = FileSystemToolResources::new()
151///     .with_policy(
152///         FileSystemToolPolicy::new()
153///             .require_read_before_write(true),
154///     );
155/// ```
156#[derive(Default)]
157pub struct FileSystemToolResources {
158    policy: FileSystemToolPolicy,
159    sessions: Mutex<BTreeMap<SessionId, SessionAccessState>>,
160}
161
162impl FileSystemToolResources {
163    /// Creates a new resource instance with all policies disabled.
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Sets the [`FileSystemToolPolicy`] that governs mutation guards.
169    pub fn with_policy(mut self, policy: FileSystemToolPolicy) -> Self {
170        self.policy = policy;
171        self
172    }
173
174    /// Records that the given path was read during `session_id`.
175    ///
176    /// This marks the path as inspected, satisfying any
177    /// `require_read_before_write` policy for subsequent mutations.
178    pub fn record_read(&self, session_id: &SessionId, path: &Path) {
179        self.record_inspected_path(session_id, path);
180    }
181
182    /// Records that the given directory was listed during `session_id`.
183    ///
184    /// Like [`record_read`](Self::record_read), this marks the path as
185    /// inspected.
186    pub fn record_list(&self, session_id: &SessionId, path: &Path) {
187        self.record_inspected_path(session_id, path);
188    }
189
190    /// Records that the given path was written during `session_id`.
191    ///
192    /// After a write the path is considered inspected, so subsequent mutations
193    /// are allowed without an additional read.
194    pub fn record_written(&self, session_id: &SessionId, path: &Path) {
195        self.record_inspected_path(session_id, path);
196    }
197
198    /// Records that a path was moved from `from` to `to` during `session_id`.
199    ///
200    /// The old path is removed from the inspected set and the new path is
201    /// added.
202    pub fn record_moved(&self, session_id: &SessionId, from: &Path, to: &Path) {
203        let mut sessions = self.sessions.lock().unwrap_or_else(|err| err.into_inner());
204        let state = sessions.entry(session_id.clone()).or_default();
205        state.inspected_paths.remove(from);
206        state.inspected_paths.insert(to.to_path_buf());
207    }
208
209    fn ensure_mutation_allowed(
210        &self,
211        session_id: Option<&SessionId>,
212        action: &'static str,
213        path: &Path,
214        target_exists: bool,
215    ) -> Result<(), ToolError> {
216        if !self.policy.require_read_before_write || !target_exists {
217            return Ok(());
218        }
219
220        let Some(session_id) = session_id else {
221            return Err(read_before_write_denial(action, path));
222        };
223
224        let sessions = self.sessions.lock().unwrap_or_else(|err| err.into_inner());
225        let Some(state) = sessions.get(session_id) else {
226            return Err(read_before_write_denial(action, path));
227        };
228
229        if state
230            .inspected_paths
231            .iter()
232            .any(|inspected| path == inspected || path.starts_with(inspected))
233        {
234            Ok(())
235        } else {
236            Err(read_before_write_denial(action, path))
237        }
238    }
239
240    fn record_inspected_path(&self, session_id: &SessionId, path: &Path) {
241        self.sessions
242            .lock()
243            .unwrap_or_else(|err| err.into_inner())
244            .entry(session_id.clone())
245            .or_default()
246            .inspected_paths
247            .insert(path.to_path_buf());
248    }
249}
250
251impl ToolResources for FileSystemToolResources {
252    fn as_any(&self) -> &dyn std::any::Any {
253        self
254    }
255}
256
257/// Reads a UTF-8 text file, optionally limited to a 1-based inclusive line range.
258///
259/// Tool name: `fs.read_file`
260///
261/// When [`FileSystemToolResources`] is available in the tool context, a
262/// successful read marks the path as inspected for the current session.
263///
264/// # Example
265///
266/// ```rust
267/// use agentkit_tool_fs::ReadFileTool;
268/// use agentkit_tools_core::Tool;
269///
270/// let tool = ReadFileTool::default();
271/// assert_eq!(&tool.spec().name.0, "fs.read_file");
272/// ```
273#[derive(Clone, Debug)]
274pub struct ReadFileTool {
275    spec: ToolSpec,
276}
277
278impl Default for ReadFileTool {
279    fn default() -> Self {
280        Self {
281            spec: ToolSpec {
282                name: ToolName::new("fs.read_file"),
283                description: "Read a UTF-8 text file from disk, optionally limited to a 1-based inclusive line range."
284                    .into(),
285                input_schema: json!({
286                    "type": "object",
287                    "properties": {
288                        "path": { "type": "string" },
289                        "from": { "type": "integer", "minimum": 1 },
290                        "to": { "type": "integer", "minimum": 1 }
291                    },
292                    "required": ["path"],
293                    "additionalProperties": false
294                }),
295                annotations: ToolAnnotations {
296                    read_only_hint: true,
297                    idempotent_hint: true,
298                    ..ToolAnnotations::default()
299                },
300                metadata: MetadataMap::new(),
301            },
302        }
303    }
304}
305
306#[derive(Deserialize)]
307struct ReadFileInput {
308    path: PathBuf,
309    from: Option<usize>,
310    to: Option<usize>,
311}
312
313#[async_trait]
314impl Tool for ReadFileTool {
315    fn spec(&self) -> &ToolSpec {
316        &self.spec
317    }
318
319    fn proposed_requests(
320        &self,
321        request: &ToolRequest,
322    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
323        let input: ReadFileInput = parse_input(&request.input)?;
324        Ok(vec![Box::new(FileSystemPermissionRequest::Read {
325            path: input.path,
326            metadata: request.metadata.clone(),
327        })])
328    }
329
330    async fn invoke(
331        &self,
332        request: ToolRequest,
333        ctx: &mut ToolContext<'_>,
334    ) -> Result<ToolResult, ToolError> {
335        let started = Instant::now();
336        let input: ReadFileInput = parse_input(&request.input)?;
337        validate_line_range(input.from, input.to)?;
338
339        let contents = async_fs::read_to_string(&input.path)
340            .await
341            .map_err(|error| ToolError::ExecutionFailed(format!("failed to read file: {error}")))?;
342        let sliced = slice_lines(&contents, input.from, input.to)?;
343
344        if let (Some(session_id), Some(resources)) = (
345            ctx.capability.session_id,
346            file_system_resources(ctx.resources),
347        ) {
348            resources.record_read(session_id, &input.path);
349        }
350
351        Ok(ToolResult {
352            result: ToolResultPart {
353                call_id: request.call_id,
354                output: ToolOutput::Text(sliced),
355                is_error: false,
356                metadata: MetadataMap::new(),
357            },
358            duration: Some(started.elapsed()),
359            metadata: MetadataMap::new(),
360        })
361    }
362}
363
364/// Writes UTF-8 text to a file, creating parent directories if needed.
365///
366/// Tool name: `fs.write_file`
367///
368/// If `require_read_before_write` is active and the target file already exists,
369/// this tool will refuse to execute unless the path was previously inspected
370/// during the same session.
371///
372/// # Example
373///
374/// ```rust
375/// use agentkit_tool_fs::WriteFileTool;
376/// use agentkit_tools_core::Tool;
377///
378/// let tool = WriteFileTool::default();
379/// assert_eq!(&tool.spec().name.0, "fs.write_file");
380/// ```
381#[derive(Clone, Debug)]
382pub struct WriteFileTool {
383    spec: ToolSpec,
384}
385
386impl Default for WriteFileTool {
387    fn default() -> Self {
388        Self {
389            spec: ToolSpec {
390                name: ToolName::new("fs.write_file"),
391                description: "Write UTF-8 text to a file, creating parent directories if needed."
392                    .into(),
393                input_schema: json!({
394                    "type": "object",
395                    "properties": {
396                        "path": { "type": "string" },
397                        "contents": { "type": "string" },
398                        "create_parents": { "type": "boolean", "default": true }
399                    },
400                    "required": ["path", "contents"],
401                    "additionalProperties": false
402                }),
403                annotations: ToolAnnotations {
404                    destructive_hint: true,
405                    idempotent_hint: false,
406                    ..ToolAnnotations::default()
407                },
408                metadata: MetadataMap::new(),
409            },
410        }
411    }
412}
413
414#[derive(Deserialize)]
415struct WriteFileInput {
416    path: PathBuf,
417    contents: String,
418    #[serde(default = "default_true")]
419    create_parents: bool,
420}
421
422#[async_trait]
423impl Tool for WriteFileTool {
424    fn spec(&self) -> &ToolSpec {
425        &self.spec
426    }
427
428    fn proposed_requests(
429        &self,
430        request: &ToolRequest,
431    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
432        let input: WriteFileInput = parse_input(&request.input)?;
433        Ok(vec![Box::new(FileSystemPermissionRequest::Write {
434            path: input.path,
435            metadata: request.metadata.clone(),
436        })])
437    }
438
439    async fn invoke(
440        &self,
441        request: ToolRequest,
442        ctx: &mut ToolContext<'_>,
443    ) -> Result<ToolResult, ToolError> {
444        let started = Instant::now();
445        let input: WriteFileInput = parse_input(&request.input)?;
446        let existed = path_exists(&input.path).await?;
447        enforce_mutation_policy(ctx, "write", &input.path, existed)?;
448
449        if input.create_parents
450            && let Some(parent) = input.path.parent()
451        {
452            async_fs::create_dir_all(parent).await.map_err(|error| {
453                ToolError::ExecutionFailed(format!(
454                    "failed to create parent directories for {}: {error}",
455                    input.path.display()
456                ))
457            })?;
458        }
459
460        async_fs::write(&input.path, input.contents.as_bytes())
461            .await
462            .map_err(|error| {
463                ToolError::ExecutionFailed(format!("failed to write file: {error}"))
464            })?;
465
466        if let (Some(session_id), Some(resources)) = (
467            ctx.capability.session_id,
468            file_system_resources(ctx.resources),
469        ) {
470            resources.record_written(session_id, &input.path);
471        }
472
473        Ok(ToolResult {
474            result: ToolResultPart {
475                call_id: request.call_id,
476                output: ToolOutput::Structured(json!({
477                    "path": input.path.display().to_string(),
478                    "bytes_written": input.contents.len(),
479                    "created": !existed,
480                })),
481                is_error: false,
482                metadata: MetadataMap::new(),
483            },
484            duration: Some(started.elapsed()),
485            metadata: MetadataMap::new(),
486        })
487    }
488}
489
490/// Replaces exact text within a UTF-8 file.
491///
492/// Tool name: `fs.replace_in_file`
493///
494/// Fails if the search text is not found. Supports replacing only the first
495/// occurrence (default) or all occurrences via the `replace_all` input flag.
496///
497/// # Example
498///
499/// ```rust
500/// use agentkit_tool_fs::ReplaceInFileTool;
501/// use agentkit_tools_core::Tool;
502///
503/// let tool = ReplaceInFileTool::default();
504/// assert_eq!(&tool.spec().name.0, "fs.replace_in_file");
505/// ```
506#[derive(Clone, Debug)]
507pub struct ReplaceInFileTool {
508    spec: ToolSpec,
509}
510
511impl Default for ReplaceInFileTool {
512    fn default() -> Self {
513        Self {
514            spec: ToolSpec {
515                name: ToolName::new("fs.replace_in_file"),
516                description:
517                    "Replace exact text in a UTF-8 file. Fails if the search text is not found."
518                        .into(),
519                input_schema: json!({
520                    "type": "object",
521                    "properties": {
522                        "path": { "type": "string" },
523                        "find": { "type": "string" },
524                        "replace": { "type": "string" },
525                        "replace_all": { "type": "boolean", "default": false }
526                    },
527                    "required": ["path", "find", "replace"],
528                    "additionalProperties": false
529                }),
530                annotations: ToolAnnotations {
531                    destructive_hint: true,
532                    idempotent_hint: false,
533                    ..ToolAnnotations::default()
534                },
535                metadata: MetadataMap::new(),
536            },
537        }
538    }
539}
540
541#[derive(Deserialize)]
542struct ReplaceInFileInput {
543    path: PathBuf,
544    find: String,
545    replace: String,
546    #[serde(default)]
547    replace_all: bool,
548}
549
550#[async_trait]
551impl Tool for ReplaceInFileTool {
552    fn spec(&self) -> &ToolSpec {
553        &self.spec
554    }
555
556    fn proposed_requests(
557        &self,
558        request: &ToolRequest,
559    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
560        let input: ReplaceInFileInput = parse_input(&request.input)?;
561        Ok(vec![Box::new(FileSystemPermissionRequest::Edit {
562            path: input.path,
563            metadata: request.metadata.clone(),
564        })])
565    }
566
567    async fn invoke(
568        &self,
569        request: ToolRequest,
570        ctx: &mut ToolContext<'_>,
571    ) -> Result<ToolResult, ToolError> {
572        let started = Instant::now();
573        let input: ReplaceInFileInput = parse_input(&request.input)?;
574        enforce_mutation_policy(ctx, "edit", &input.path, true)?;
575
576        let contents = async_fs::read_to_string(&input.path)
577            .await
578            .map_err(|error| ToolError::ExecutionFailed(format!("failed to read file: {error}")))?;
579
580        let replacement_count = contents.matches(&input.find).count();
581        if replacement_count == 0 {
582            return Err(ToolError::ExecutionFailed(format!(
583                "search text not found in {}",
584                input.path.display()
585            )));
586        }
587
588        let updated = if input.replace_all {
589            contents.replace(&input.find, &input.replace)
590        } else {
591            contents.replacen(&input.find, &input.replace, 1)
592        };
593        let applied = if input.replace_all {
594            replacement_count
595        } else {
596            1
597        };
598
599        async_fs::write(&input.path, updated.as_bytes())
600            .await
601            .map_err(|error| {
602                ToolError::ExecutionFailed(format!("failed to write file: {error}"))
603            })?;
604
605        if let (Some(session_id), Some(resources)) = (
606            ctx.capability.session_id,
607            file_system_resources(ctx.resources),
608        ) {
609            resources.record_written(session_id, &input.path);
610        }
611
612        Ok(ToolResult {
613            result: ToolResultPart {
614                call_id: request.call_id,
615                output: ToolOutput::Structured(json!({
616                    "path": input.path.display().to_string(),
617                    "replacements": applied,
618                })),
619                is_error: false,
620                metadata: MetadataMap::new(),
621            },
622            duration: Some(started.elapsed()),
623            metadata: MetadataMap::new(),
624        })
625    }
626}
627
628/// Moves or renames a file or directory.
629///
630/// Tool name: `fs.move`
631///
632/// Optionally creates parent directories for the destination and can overwrite
633/// an existing target when `overwrite` is set. Subject to `require_read_before_write`
634/// policy on the source path.
635///
636/// # Example
637///
638/// ```rust
639/// use agentkit_tool_fs::MoveTool;
640/// use agentkit_tools_core::Tool;
641///
642/// let tool = MoveTool::default();
643/// assert_eq!(&tool.spec().name.0, "fs.move");
644/// ```
645#[derive(Clone, Debug)]
646pub struct MoveTool {
647    spec: ToolSpec,
648}
649
650impl Default for MoveTool {
651    fn default() -> Self {
652        Self {
653            spec: ToolSpec {
654                name: ToolName::new("fs.move"),
655                description: "Move or rename a file or directory.".into(),
656                input_schema: json!({
657                    "type": "object",
658                    "properties": {
659                        "from": { "type": "string" },
660                        "to": { "type": "string" },
661                        "create_parents": { "type": "boolean", "default": true },
662                        "overwrite": { "type": "boolean", "default": false }
663                    },
664                    "required": ["from", "to"],
665                    "additionalProperties": false
666                }),
667                annotations: ToolAnnotations {
668                    destructive_hint: true,
669                    idempotent_hint: false,
670                    ..ToolAnnotations::default()
671                },
672                metadata: MetadataMap::new(),
673            },
674        }
675    }
676}
677
678#[derive(Deserialize)]
679struct MoveInput {
680    from: PathBuf,
681    to: PathBuf,
682    #[serde(default = "default_true")]
683    create_parents: bool,
684    #[serde(default)]
685    overwrite: bool,
686}
687
688#[async_trait]
689impl Tool for MoveTool {
690    fn spec(&self) -> &ToolSpec {
691        &self.spec
692    }
693
694    fn proposed_requests(
695        &self,
696        request: &ToolRequest,
697    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
698        let input: MoveInput = parse_input(&request.input)?;
699        Ok(vec![Box::new(FileSystemPermissionRequest::Move {
700            from: input.from,
701            to: input.to,
702            metadata: request.metadata.clone(),
703        })])
704    }
705
706    async fn invoke(
707        &self,
708        request: ToolRequest,
709        ctx: &mut ToolContext<'_>,
710    ) -> Result<ToolResult, ToolError> {
711        let started = Instant::now();
712        let input: MoveInput = parse_input(&request.input)?;
713        enforce_mutation_policy(ctx, "move", &input.from, true)?;
714
715        if path_exists(&input.to).await? {
716            if input.overwrite {
717                remove_path(&input.to, true, true).await?;
718            } else {
719                return Err(ToolError::ExecutionFailed(format!(
720                    "destination {} already exists",
721                    input.to.display()
722                )));
723            }
724        }
725
726        if input.create_parents
727            && let Some(parent) = input.to.parent()
728        {
729            async_fs::create_dir_all(parent).await.map_err(|error| {
730                ToolError::ExecutionFailed(format!(
731                    "failed to create parent directories for {}: {error}",
732                    input.to.display()
733                ))
734            })?;
735        }
736
737        async_fs::rename(&input.from, &input.to)
738            .await
739            .map_err(|error| {
740                ToolError::ExecutionFailed(format!(
741                    "failed to move {} to {}: {error}",
742                    input.from.display(),
743                    input.to.display()
744                ))
745            })?;
746
747        if let (Some(session_id), Some(resources)) = (
748            ctx.capability.session_id,
749            file_system_resources(ctx.resources),
750        ) {
751            resources.record_moved(session_id, &input.from, &input.to);
752        }
753
754        Ok(ToolResult {
755            result: ToolResultPart {
756                call_id: request.call_id,
757                output: ToolOutput::Structured(json!({
758                    "from": input.from.display().to_string(),
759                    "to": input.to.display().to_string(),
760                    "moved": true,
761                })),
762                is_error: false,
763                metadata: MetadataMap::new(),
764            },
765            duration: Some(started.elapsed()),
766            metadata: MetadataMap::new(),
767        })
768    }
769}
770
771/// Deletes a file or directory.
772///
773/// Tool name: `fs.delete`
774///
775/// For directories, set `recursive` to remove non-empty directories. The
776/// `missing_ok` flag suppresses errors when the target does not exist.
777///
778/// # Example
779///
780/// ```rust
781/// use agentkit_tool_fs::DeleteTool;
782/// use agentkit_tools_core::Tool;
783///
784/// let tool = DeleteTool::default();
785/// assert_eq!(&tool.spec().name.0, "fs.delete");
786/// ```
787#[derive(Clone, Debug)]
788pub struct DeleteTool {
789    spec: ToolSpec,
790}
791
792impl Default for DeleteTool {
793    fn default() -> Self {
794        Self {
795            spec: ToolSpec {
796                name: ToolName::new("fs.delete"),
797                description: "Delete a file or directory.".into(),
798                input_schema: json!({
799                    "type": "object",
800                    "properties": {
801                        "path": { "type": "string" },
802                        "recursive": { "type": "boolean", "default": false },
803                        "missing_ok": { "type": "boolean", "default": false }
804                    },
805                    "required": ["path"],
806                    "additionalProperties": false
807                }),
808                annotations: ToolAnnotations {
809                    destructive_hint: true,
810                    idempotent_hint: false,
811                    ..ToolAnnotations::default()
812                },
813                metadata: MetadataMap::new(),
814            },
815        }
816    }
817}
818
819#[derive(Deserialize)]
820struct DeleteInput {
821    path: PathBuf,
822    #[serde(default)]
823    recursive: bool,
824    #[serde(default)]
825    missing_ok: bool,
826}
827
828#[async_trait]
829impl Tool for DeleteTool {
830    fn spec(&self) -> &ToolSpec {
831        &self.spec
832    }
833
834    fn proposed_requests(
835        &self,
836        request: &ToolRequest,
837    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
838        let input: DeleteInput = parse_input(&request.input)?;
839        Ok(vec![Box::new(FileSystemPermissionRequest::Delete {
840            path: input.path,
841            metadata: request.metadata.clone(),
842        })])
843    }
844
845    async fn invoke(
846        &self,
847        request: ToolRequest,
848        ctx: &mut ToolContext<'_>,
849    ) -> Result<ToolResult, ToolError> {
850        let started = Instant::now();
851        let input: DeleteInput = parse_input(&request.input)?;
852        let existed = path_exists(&input.path).await?;
853        if !existed && input.missing_ok {
854            return Ok(ToolResult {
855                result: ToolResultPart {
856                    call_id: request.call_id,
857                    output: ToolOutput::Structured(json!({
858                        "path": input.path.display().to_string(),
859                        "deleted": false,
860                        "missing": true,
861                    })),
862                    is_error: false,
863                    metadata: MetadataMap::new(),
864                },
865                duration: Some(started.elapsed()),
866                metadata: MetadataMap::new(),
867            });
868        }
869
870        enforce_mutation_policy(ctx, "delete", &input.path, existed)?;
871        remove_path(&input.path, input.recursive, false).await?;
872
873        Ok(ToolResult {
874            result: ToolResultPart {
875                call_id: request.call_id,
876                output: ToolOutput::Structured(json!({
877                    "path": input.path.display().to_string(),
878                    "deleted": true,
879                })),
880                is_error: false,
881                metadata: MetadataMap::new(),
882            },
883            duration: Some(started.elapsed()),
884            metadata: MetadataMap::new(),
885        })
886    }
887}
888
889/// Lists entries in a directory.
890///
891/// Tool name: `fs.list_directory`
892///
893/// Returns a JSON array of objects with `name`, `path`, and `kind` (one of
894/// `"file"`, `"directory"`, or `"symlink"`) for each entry. A successful list
895/// marks the directory as inspected for `require_read_before_write` purposes.
896///
897/// # Example
898///
899/// ```rust
900/// use agentkit_tool_fs::ListDirectoryTool;
901/// use agentkit_tools_core::Tool;
902///
903/// let tool = ListDirectoryTool::default();
904/// assert_eq!(&tool.spec().name.0, "fs.list_directory");
905/// ```
906#[derive(Clone, Debug)]
907pub struct ListDirectoryTool {
908    spec: ToolSpec,
909}
910
911impl Default for ListDirectoryTool {
912    fn default() -> Self {
913        Self {
914            spec: ToolSpec {
915                name: ToolName::new("fs.list_directory"),
916                description: "List the entries in a directory.".into(),
917                input_schema: json!({
918                    "type": "object",
919                    "properties": {
920                        "path": { "type": "string" }
921                    },
922                    "required": ["path"],
923                    "additionalProperties": false
924                }),
925                annotations: ToolAnnotations {
926                    read_only_hint: true,
927                    idempotent_hint: true,
928                    ..ToolAnnotations::default()
929                },
930                metadata: MetadataMap::new(),
931            },
932        }
933    }
934}
935
936#[derive(Deserialize)]
937struct ListDirectoryInput {
938    path: PathBuf,
939}
940
941#[async_trait]
942impl Tool for ListDirectoryTool {
943    fn spec(&self) -> &ToolSpec {
944        &self.spec
945    }
946
947    fn proposed_requests(
948        &self,
949        request: &ToolRequest,
950    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
951        let input: ListDirectoryInput = parse_input(&request.input)?;
952        Ok(vec![Box::new(FileSystemPermissionRequest::List {
953            path: input.path,
954            metadata: request.metadata.clone(),
955        })])
956    }
957
958    async fn invoke(
959        &self,
960        request: ToolRequest,
961        ctx: &mut ToolContext<'_>,
962    ) -> Result<ToolResult, ToolError> {
963        let started = Instant::now();
964        let input: ListDirectoryInput = parse_input(&request.input)?;
965        let mut entries = Vec::new();
966        let mut dir = async_fs::read_dir(&input.path).await.map_err(|error| {
967            ToolError::ExecutionFailed(format!(
968                "failed to list directory {}: {error}",
969                input.path.display()
970            ))
971        })?;
972
973        while let Some(entry) = dir.next().await {
974            let entry = entry.map_err(|error| {
975                ToolError::ExecutionFailed(format!(
976                    "failed to read directory entry in {}: {error}",
977                    input.path.display()
978                ))
979            })?;
980            let file_type = entry.file_type().await.map_err(|error| {
981                ToolError::ExecutionFailed(format!(
982                    "failed to inspect directory entry in {}: {error}",
983                    input.path.display()
984                ))
985            })?;
986            entries.push(json!({
987                "name": path_name(entry.path())?,
988                "path": entry.path().display().to_string(),
989                "kind": file_kind_label(&file_type),
990            }));
991        }
992
993        if let (Some(session_id), Some(resources)) = (
994            ctx.capability.session_id,
995            file_system_resources(ctx.resources),
996        ) {
997            resources.record_list(session_id, &input.path);
998        }
999
1000        Ok(ToolResult {
1001            result: ToolResultPart {
1002                call_id: request.call_id,
1003                output: ToolOutput::Structured(Value::Array(entries)),
1004                is_error: false,
1005                metadata: MetadataMap::new(),
1006            },
1007            duration: Some(started.elapsed()),
1008            metadata: MetadataMap::new(),
1009        })
1010    }
1011}
1012
1013/// Creates a directory and any missing parent directories.
1014///
1015/// Tool name: `fs.create_directory`
1016///
1017/// This is idempotent: calling it on an already-existing directory succeeds
1018/// without error.
1019///
1020/// # Example
1021///
1022/// ```rust
1023/// use agentkit_tool_fs::CreateDirectoryTool;
1024/// use agentkit_tools_core::Tool;
1025///
1026/// let tool = CreateDirectoryTool::default();
1027/// assert_eq!(&tool.spec().name.0, "fs.create_directory");
1028/// ```
1029#[derive(Clone, Debug)]
1030pub struct CreateDirectoryTool {
1031    spec: ToolSpec,
1032}
1033
1034impl Default for CreateDirectoryTool {
1035    fn default() -> Self {
1036        Self {
1037            spec: ToolSpec {
1038                name: ToolName::new("fs.create_directory"),
1039                description: "Create a directory and any missing parent directories.".into(),
1040                input_schema: json!({
1041                    "type": "object",
1042                    "properties": {
1043                        "path": { "type": "string" }
1044                    },
1045                    "required": ["path"],
1046                    "additionalProperties": false
1047                }),
1048                annotations: ToolAnnotations {
1049                    destructive_hint: true,
1050                    idempotent_hint: true,
1051                    ..ToolAnnotations::default()
1052                },
1053                metadata: MetadataMap::new(),
1054            },
1055        }
1056    }
1057}
1058
1059#[derive(Deserialize)]
1060struct CreateDirectoryInput {
1061    path: PathBuf,
1062}
1063
1064#[async_trait]
1065impl Tool for CreateDirectoryTool {
1066    fn spec(&self) -> &ToolSpec {
1067        &self.spec
1068    }
1069
1070    fn proposed_requests(
1071        &self,
1072        request: &ToolRequest,
1073    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
1074        let input: CreateDirectoryInput = parse_input(&request.input)?;
1075        Ok(vec![Box::new(FileSystemPermissionRequest::CreateDir {
1076            path: input.path,
1077            metadata: request.metadata.clone(),
1078        })])
1079    }
1080
1081    async fn invoke(
1082        &self,
1083        request: ToolRequest,
1084        _ctx: &mut ToolContext<'_>,
1085    ) -> Result<ToolResult, ToolError> {
1086        let started = Instant::now();
1087        let input: CreateDirectoryInput = parse_input(&request.input)?;
1088        async_fs::create_dir_all(&input.path)
1089            .await
1090            .map_err(|error| {
1091                ToolError::ExecutionFailed(format!(
1092                    "failed to create directory {}: {error}",
1093                    input.path.display()
1094                ))
1095            })?;
1096
1097        Ok(ToolResult {
1098            result: ToolResultPart {
1099                call_id: request.call_id,
1100                output: ToolOutput::Structured(json!({
1101                    "path": input.path.display().to_string(),
1102                    "created": true,
1103                })),
1104                is_error: false,
1105                metadata: MetadataMap::new(),
1106            },
1107            duration: Some(started.elapsed()),
1108            metadata: MetadataMap::new(),
1109        })
1110    }
1111}
1112
1113fn parse_input<T>(value: &Value) -> Result<T, ToolError>
1114where
1115    T: for<'de> Deserialize<'de>,
1116{
1117    serde_json::from_value(value.clone())
1118        .map_err(|error| ToolError::InvalidInput(format!("invalid tool input: {error}")))
1119}
1120
1121fn default_true() -> bool {
1122    true
1123}
1124
1125fn validate_line_range(from: Option<usize>, to: Option<usize>) -> Result<(), ToolError> {
1126    if matches!((from, to), (Some(start), Some(end)) if end < start) {
1127        return Err(ToolError::InvalidInput(
1128            FileSystemToolError::InvalidLineRange { from, to }.to_string(),
1129        ));
1130    }
1131    Ok(())
1132}
1133
1134fn slice_lines(
1135    contents: &str,
1136    from: Option<usize>,
1137    to: Option<usize>,
1138) -> Result<String, ToolError> {
1139    validate_line_range(from, to)?;
1140    if from.is_none() && to.is_none() {
1141        return Ok(contents.to_string());
1142    }
1143
1144    let start = from.unwrap_or(1);
1145    let end = to.unwrap_or(usize::MAX);
1146    let selected = contents
1147        .lines()
1148        .enumerate()
1149        .filter_map(|(index, line)| {
1150            let line_number = index + 1;
1151            (line_number >= start && line_number <= end).then_some(line)
1152        })
1153        .collect::<Vec<_>>();
1154
1155    Ok(selected.join("\n"))
1156}
1157
1158fn file_system_resources(resources: &dyn ToolResources) -> Option<&FileSystemToolResources> {
1159    resources.as_any().downcast_ref::<FileSystemToolResources>()
1160}
1161
1162fn enforce_mutation_policy(
1163    ctx: &ToolContext<'_>,
1164    action: &'static str,
1165    path: &Path,
1166    target_exists: bool,
1167) -> Result<(), ToolError> {
1168    let Some(resources) = file_system_resources(ctx.resources) else {
1169        return Ok(());
1170    };
1171
1172    resources.ensure_mutation_allowed(ctx.capability.session_id, action, path, target_exists)
1173}
1174
1175fn read_before_write_denial(action: &'static str, path: &Path) -> ToolError {
1176    ToolError::PermissionDenied(PermissionDenial {
1177        code: PermissionCode::CustomPolicyDenied,
1178        message: format!(
1179            "filesystem policy requires reading {} before attempting to {} it",
1180            path.display(),
1181            action
1182        ),
1183        metadata: MetadataMap::new(),
1184    })
1185}
1186
1187async fn path_exists(path: &Path) -> Result<bool, ToolError> {
1188    Ok(async_fs::metadata(path).await.is_ok())
1189}
1190
1191async fn remove_path(path: &Path, recursive: bool, overwrite: bool) -> Result<(), ToolError> {
1192    let metadata = async_fs::metadata(path).await.map_err(|error| {
1193        ToolError::ExecutionFailed(format!(
1194            "failed to inspect {} before deletion: {error}",
1195            path.display()
1196        ))
1197    })?;
1198
1199    if metadata.is_dir() {
1200        if recursive || overwrite {
1201            async_fs::remove_dir_all(path).await.map_err(|error| {
1202                ToolError::ExecutionFailed(format!(
1203                    "failed to remove directory {}: {error}",
1204                    path.display()
1205                ))
1206            })?;
1207        } else {
1208            async_fs::remove_dir(path).await.map_err(|error| {
1209                ToolError::ExecutionFailed(format!(
1210                    "failed to remove directory {}: {error}",
1211                    path.display()
1212                ))
1213            })?;
1214        }
1215    } else {
1216        async_fs::remove_file(path).await.map_err(|error| {
1217            ToolError::ExecutionFailed(format!("failed to remove file {}: {error}", path.display()))
1218        })?;
1219    }
1220
1221    Ok(())
1222}
1223
1224fn path_name(path: impl AsRef<Path>) -> Result<String, ToolError> {
1225    let path = path.as_ref();
1226    let name = path.file_name().ok_or_else(|| {
1227        ToolError::ExecutionFailed(format!("path {} has no file name", path.display()))
1228    })?;
1229
1230    name.to_str().map(|value| value.to_string()).ok_or_else(|| {
1231        ToolError::ExecutionFailed(
1232            FileSystemToolError::InvalidUtf8Path(path.to_path_buf()).to_string(),
1233        )
1234    })
1235}
1236
1237fn file_kind_label(file_type: &std::fs::FileType) -> &'static str {
1238    if file_type.is_dir() {
1239        "directory"
1240    } else if file_type.is_symlink() {
1241        "symlink"
1242    } else {
1243        "file"
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use std::time::{SystemTime, UNIX_EPOCH};
1250
1251    use agentkit_capabilities::CapabilityContext;
1252    use agentkit_core::{SessionId, ToolCallId, TurnId};
1253    use agentkit_tools_core::{
1254        BasicToolExecutor, PermissionChecker, PermissionDecision, ToolExecutionOutcome,
1255        ToolExecutor,
1256    };
1257
1258    use super::*;
1259
1260    struct AllowAll;
1261
1262    impl PermissionChecker for AllowAll {
1263        fn evaluate(&self, _request: &dyn PermissionRequest) -> PermissionDecision {
1264            PermissionDecision::Allow
1265        }
1266    }
1267
1268    fn temp_dir(name: &str) -> PathBuf {
1269        let nanos = SystemTime::now()
1270            .duration_since(UNIX_EPOCH)
1271            .expect("time moved backwards")
1272            .as_nanos();
1273        std::env::temp_dir().join(format!("agentkit-{name}-{nanos}"))
1274    }
1275
1276    fn tool_context<'a>(
1277        session_id: &'a SessionId,
1278        turn_id: &'a TurnId,
1279        metadata: &'a MetadataMap,
1280        resources: &'a dyn ToolResources,
1281    ) -> ToolContext<'a> {
1282        ToolContext {
1283            capability: CapabilityContext {
1284                session_id: Some(session_id),
1285                turn_id: Some(turn_id),
1286                metadata,
1287            },
1288            permissions: &AllowAll,
1289            resources,
1290            cancellation: None,
1291        }
1292    }
1293
1294    fn request(
1295        tool_name: &str,
1296        input: Value,
1297        session_id: &SessionId,
1298        turn_id: &TurnId,
1299    ) -> ToolRequest {
1300        ToolRequest {
1301            call_id: ToolCallId::new(format!("call-{tool_name}")),
1302            tool_name: ToolName::new(tool_name),
1303            input,
1304            session_id: session_id.clone(),
1305            turn_id: turn_id.clone(),
1306            metadata: MetadataMap::new(),
1307        }
1308    }
1309
1310    #[test]
1311    fn registry_exposes_expected_tools() {
1312        let specs = registry().specs();
1313        let names: Vec<_> = specs.into_iter().map(|spec| spec.name.0).collect();
1314        assert!(names.contains(&"fs.read_file".into()));
1315        assert!(names.contains(&"fs.write_file".into()));
1316        assert!(names.contains(&"fs.replace_in_file".into()));
1317        assert!(names.contains(&"fs.move".into()));
1318        assert!(names.contains(&"fs.delete".into()));
1319        assert!(names.contains(&"fs.list_directory".into()));
1320        assert!(names.contains(&"fs.create_directory".into()));
1321    }
1322
1323    #[tokio::test]
1324    async fn write_then_ranged_read_roundtrip() {
1325        let root = temp_dir("fs");
1326        async_fs::create_dir_all(&root).await.unwrap();
1327        let target = root.join("note.txt");
1328        let session_id = SessionId::new("session-1");
1329        let turn_id = TurnId::new("turn-1");
1330
1331        let executor = BasicToolExecutor::new(registry());
1332        let metadata = MetadataMap::new();
1333        let mut ctx = tool_context(&session_id, &turn_id, &metadata, &());
1334
1335        let write = executor
1336            .execute(
1337                request(
1338                    "fs.write_file",
1339                    json!({
1340                        "path": target.display().to_string(),
1341                        "contents": "alpha\nbeta\ngamma"
1342                    }),
1343                    &session_id,
1344                    &turn_id,
1345                ),
1346                &mut ctx,
1347            )
1348            .await;
1349        assert!(matches!(write, ToolExecutionOutcome::Completed(_)));
1350
1351        let read = executor
1352            .execute(
1353                request(
1354                    "fs.read_file",
1355                    json!({
1356                        "path": target.display().to_string(),
1357                        "from": 2,
1358                        "to": 3
1359                    }),
1360                    &session_id,
1361                    &turn_id,
1362                ),
1363                &mut ctx,
1364            )
1365            .await;
1366
1367        match read {
1368            ToolExecutionOutcome::Completed(result) => {
1369                assert_eq!(result.result.output, ToolOutput::Text("beta\ngamma".into()));
1370            }
1371            other => panic!("unexpected outcome: {other:?}"),
1372        }
1373
1374        let _ = async_fs::remove_dir_all(root).await;
1375    }
1376
1377    #[tokio::test]
1378    async fn replace_move_and_delete_work() {
1379        let root = temp_dir("fs-edit");
1380        async_fs::create_dir_all(&root).await.unwrap();
1381        let source = root.join("source.txt");
1382        let destination = root.join("archive").join("renamed.txt");
1383        async_fs::write(&source, "hello world").await.unwrap();
1384
1385        let resources = FileSystemToolResources::new()
1386            .with_policy(FileSystemToolPolicy::new().require_read_before_write(true));
1387        let session_id = SessionId::new("session-2");
1388        let turn_id = TurnId::new("turn-2");
1389        let metadata = MetadataMap::new();
1390        let executor = BasicToolExecutor::new(registry());
1391        let mut ctx = tool_context(&session_id, &turn_id, &metadata, &resources);
1392
1393        let denied_edit = executor
1394            .execute(
1395                request(
1396                    "fs.replace_in_file",
1397                    json!({
1398                        "path": source.display().to_string(),
1399                        "find": "world",
1400                        "replace": "agentkit"
1401                    }),
1402                    &session_id,
1403                    &turn_id,
1404                ),
1405                &mut ctx,
1406            )
1407            .await;
1408        assert!(matches!(
1409            denied_edit,
1410            ToolExecutionOutcome::Failed(ToolError::PermissionDenied(_))
1411        ));
1412
1413        let read = executor
1414            .execute(
1415                request(
1416                    "fs.read_file",
1417                    json!({
1418                        "path": source.display().to_string()
1419                    }),
1420                    &session_id,
1421                    &turn_id,
1422                ),
1423                &mut ctx,
1424            )
1425            .await;
1426        assert!(matches!(read, ToolExecutionOutcome::Completed(_)));
1427
1428        let replace = executor
1429            .execute(
1430                request(
1431                    "fs.replace_in_file",
1432                    json!({
1433                        "path": source.display().to_string(),
1434                        "find": "world",
1435                        "replace": "agentkit"
1436                    }),
1437                    &session_id,
1438                    &turn_id,
1439                ),
1440                &mut ctx,
1441            )
1442            .await;
1443        assert!(matches!(replace, ToolExecutionOutcome::Completed(_)));
1444
1445        let move_result = executor
1446            .execute(
1447                request(
1448                    "fs.move",
1449                    json!({
1450                        "from": source.display().to_string(),
1451                        "to": destination.display().to_string()
1452                    }),
1453                    &session_id,
1454                    &turn_id,
1455                ),
1456                &mut ctx,
1457            )
1458            .await;
1459        assert!(matches!(move_result, ToolExecutionOutcome::Completed(_)));
1460
1461        let read_moved = async_fs::read_to_string(&destination).await.unwrap();
1462        assert_eq!(read_moved, "hello agentkit");
1463
1464        let delete = executor
1465            .execute(
1466                request(
1467                    "fs.delete",
1468                    json!({
1469                        "path": destination.display().to_string()
1470                    }),
1471                    &session_id,
1472                    &turn_id,
1473                ),
1474                &mut ctx,
1475            )
1476            .await;
1477        assert!(matches!(delete, ToolExecutionOutcome::Completed(_)));
1478        assert!(!path_exists(&destination).await.unwrap());
1479
1480        let _ = async_fs::remove_dir_all(root).await;
1481    }
1482}