Skip to main content

clawft_kernel/wasm_runner/
tools_fs.rs

1//! Built-in filesystem tool implementations.
2
3use chrono::{DateTime, Utc};
4
5use super::catalog::builtin_tool_catalog;
6use super::registry::BuiltinTool;
7use super::types::*;
8
9/// Max file read size (8 MiB, matching PluginSandbox).
10const MAX_READ_SIZE: u64 = 8 * 1024 * 1024;
11
12/// Built-in `fs.read_file` tool.
13///
14/// Reads file contents with optional offset and limit.
15/// Always runs natively (no WASM needed for reference impl).
16/// Supports multi-layer sandboxing via [`SandboxConfig`].
17pub struct FsReadFileTool {
18    spec: BuiltinToolSpec,
19    sandbox: SandboxConfig,
20}
21
22impl Default for FsReadFileTool {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl FsReadFileTool {
29    pub fn new() -> Self {
30        let catalog = builtin_tool_catalog();
31        let spec = catalog
32            .into_iter()
33            .find(|s| s.name == "fs.read_file")
34            .expect("fs.read_file must be in catalog");
35        Self {
36            spec,
37            sandbox: SandboxConfig::default(),
38        }
39    }
40
41    /// Create a sandboxed instance that restricts file access.
42    pub fn with_sandbox(sandbox: SandboxConfig) -> Self {
43        let catalog = builtin_tool_catalog();
44        let spec = catalog
45            .into_iter()
46            .find(|s| s.name == "fs.read_file")
47            .expect("fs.read_file must be in catalog");
48        Self { spec, sandbox }
49    }
50}
51
52impl BuiltinTool for FsReadFileTool {
53    fn name(&self) -> &str {
54        "fs.read_file"
55    }
56
57    fn spec(&self) -> &BuiltinToolSpec {
58        &self.spec
59    }
60
61    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
62        let path = args
63            .get("path")
64            .and_then(|v| v.as_str())
65            .ok_or_else(|| ToolError::InvalidArgs("missing 'path' parameter".into()))?;
66
67        let path = std::path::Path::new(path);
68
69        // Sandbox path check (K4 B1)
70        if !self.sandbox.is_path_allowed(path) {
71            return Err(ToolError::PermissionDenied(format!(
72                "path outside sandbox: {}",
73                path.display()
74            )));
75        }
76
77        if !path.exists() {
78            return Err(ToolError::FileNotFound(path.display().to_string()));
79        }
80
81        let metadata =
82            std::fs::metadata(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
83
84        if metadata.len() > MAX_READ_SIZE {
85            return Err(ToolError::FileTooLarge {
86                size: metadata.len(),
87                limit: MAX_READ_SIZE,
88            });
89        }
90
91        let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
92        let limit = args
93            .get("limit")
94            .and_then(|v| v.as_u64())
95            .map(|v| v as usize);
96
97        let bytes = std::fs::read(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
98
99        let end = match limit {
100            Some(l) => std::cmp::min(offset + l, bytes.len()),
101            None => bytes.len(),
102        };
103        let start = std::cmp::min(offset, bytes.len());
104        let slice = &bytes[start..end];
105
106        let content = String::from_utf8_lossy(slice).into_owned();
107        let modified = metadata
108            .modified()
109            .ok()
110            .map(|t| {
111                let dt: DateTime<Utc> = t.into();
112                dt.to_rfc3339()
113            })
114            .unwrap_or_default();
115
116        Ok(serde_json::json!({
117            "content": content,
118            "size": metadata.len(),
119            "modified": modified,
120        }))
121    }
122}
123
124/// Built-in `fs.write_file` tool.
125pub struct FsWriteFileTool {
126    spec: BuiltinToolSpec,
127    sandbox: SandboxConfig,
128}
129
130impl FsWriteFileTool {
131    pub fn new() -> Self {
132        let spec = builtin_tool_catalog()
133            .into_iter()
134            .find(|s| s.name == "fs.write_file")
135            .unwrap();
136        Self {
137            spec,
138            sandbox: SandboxConfig::default(),
139        }
140    }
141    pub fn with_sandbox(sandbox: SandboxConfig) -> Self {
142        let spec = builtin_tool_catalog()
143            .into_iter()
144            .find(|s| s.name == "fs.write_file")
145            .unwrap();
146        Self { spec, sandbox }
147    }
148}
149
150impl BuiltinTool for FsWriteFileTool {
151    fn name(&self) -> &str {
152        "fs.write_file"
153    }
154    fn spec(&self) -> &BuiltinToolSpec {
155        &self.spec
156    }
157    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
158        let path_str = args
159            .get("path")
160            .and_then(|v| v.as_str())
161            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
162        let content = args
163            .get("content")
164            .and_then(|v| v.as_str())
165            .ok_or_else(|| ToolError::InvalidArgs("missing 'content'".into()))?;
166        let append = args
167            .get("append")
168            .and_then(|v| v.as_bool())
169            .unwrap_or(false);
170        let path = std::path::Path::new(path_str);
171        if !self.sandbox.is_path_allowed(path) {
172            return Err(ToolError::PermissionDenied(format!(
173                "path outside sandbox: {}",
174                path.display()
175            )));
176        }
177        if append {
178            use std::io::Write;
179            let mut f = std::fs::OpenOptions::new()
180                .create(true)
181                .append(true)
182                .open(path)
183                .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
184            f.write_all(content.as_bytes())
185                .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
186        } else {
187            std::fs::write(path, content).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
188        }
189
190        // Chain event: log filesystem write
191        #[cfg(feature = "exochain")]
192        clawft_core::chain_event::push_chain_event(
193            "wasm_fs",
194            "wasm.fs.write",
195            Some(
196                serde_json::json!({"path": path_str, "bytes_written": content.len(), "append": append}),
197            ),
198        );
199
200        Ok(serde_json::json!({"written": content.len(), "path": path_str}))
201    }
202}
203
204/// Built-in `fs.read_dir` tool.
205pub struct FsReadDirTool {
206    spec: BuiltinToolSpec,
207    sandbox: SandboxConfig,
208}
209
210impl FsReadDirTool {
211    pub fn new() -> Self {
212        let spec = builtin_tool_catalog()
213            .into_iter()
214            .find(|s| s.name == "fs.read_dir")
215            .unwrap();
216        Self {
217            spec,
218            sandbox: SandboxConfig::default(),
219        }
220    }
221    pub fn with_sandbox(sandbox: SandboxConfig) -> Self {
222        let spec = builtin_tool_catalog()
223            .into_iter()
224            .find(|s| s.name == "fs.read_dir")
225            .unwrap();
226        Self { spec, sandbox }
227    }
228}
229
230impl BuiltinTool for FsReadDirTool {
231    fn name(&self) -> &str {
232        "fs.read_dir"
233    }
234    fn spec(&self) -> &BuiltinToolSpec {
235        &self.spec
236    }
237    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
238        let path_str = args
239            .get("path")
240            .and_then(|v| v.as_str())
241            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
242        let path = std::path::Path::new(path_str);
243        if !self.sandbox.is_path_allowed(path) {
244            return Err(ToolError::PermissionDenied(format!(
245                "path outside sandbox: {}",
246                path.display()
247            )));
248        }
249        if !path.exists() {
250            return Err(ToolError::FileNotFound(path.display().to_string()));
251        }
252        let entries: Vec<serde_json::Value> = std::fs::read_dir(path)
253            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?
254            .filter_map(|e| e.ok())
255            .map(|e| {
256                let ft = e.file_type().ok();
257                serde_json::json!({
258                    "name": e.file_name().to_string_lossy(),
259                    "is_dir": ft.as_ref().map(|t| t.is_dir()).unwrap_or(false),
260                    "is_file": ft.as_ref().map(|t| t.is_file()).unwrap_or(false),
261                })
262            })
263            .collect();
264        Ok(serde_json::json!({"entries": entries, "count": entries.len()}))
265    }
266}
267
268/// Built-in `fs.create_dir` tool.
269pub struct FsCreateDirTool {
270    spec: BuiltinToolSpec,
271    sandbox: SandboxConfig,
272}
273
274impl FsCreateDirTool {
275    pub fn new() -> Self {
276        let spec = builtin_tool_catalog()
277            .into_iter()
278            .find(|s| s.name == "fs.create_dir")
279            .unwrap();
280        Self {
281            spec,
282            sandbox: SandboxConfig::default(),
283        }
284    }
285}
286
287impl BuiltinTool for FsCreateDirTool {
288    fn name(&self) -> &str {
289        "fs.create_dir"
290    }
291    fn spec(&self) -> &BuiltinToolSpec {
292        &self.spec
293    }
294    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
295        let path_str = args
296            .get("path")
297            .and_then(|v| v.as_str())
298            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
299        let path = std::path::Path::new(path_str);
300        if !self.sandbox.is_path_allowed(path) {
301            return Err(ToolError::PermissionDenied(format!(
302                "path outside sandbox: {}",
303                path.display()
304            )));
305        }
306        let recursive = args
307            .get("recursive")
308            .and_then(|v| v.as_bool())
309            .unwrap_or(true);
310        if recursive {
311            std::fs::create_dir_all(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
312        } else {
313            std::fs::create_dir(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
314        }
315
316        // Chain event: log directory creation
317        #[cfg(feature = "exochain")]
318        clawft_core::chain_event::push_chain_event(
319            "wasm_fs",
320            "wasm.fs.create_dir",
321            Some(serde_json::json!({"path": path_str, "recursive": recursive})),
322        );
323
324        Ok(serde_json::json!({"created": path_str}))
325    }
326}
327
328/// Built-in `fs.remove` tool.
329pub struct FsRemoveTool {
330    spec: BuiltinToolSpec,
331    sandbox: SandboxConfig,
332}
333
334impl FsRemoveTool {
335    pub fn new() -> Self {
336        let spec = builtin_tool_catalog()
337            .into_iter()
338            .find(|s| s.name == "fs.remove")
339            .unwrap();
340        Self {
341            spec,
342            sandbox: SandboxConfig::default(),
343        }
344    }
345}
346
347impl BuiltinTool for FsRemoveTool {
348    fn name(&self) -> &str {
349        "fs.remove"
350    }
351    fn spec(&self) -> &BuiltinToolSpec {
352        &self.spec
353    }
354    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
355        let path_str = args
356            .get("path")
357            .and_then(|v| v.as_str())
358            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
359        let path = std::path::Path::new(path_str);
360        if !self.sandbox.is_path_allowed(path) {
361            return Err(ToolError::PermissionDenied(format!(
362                "path outside sandbox: {}",
363                path.display()
364            )));
365        }
366        if !path.exists() {
367            return Err(ToolError::FileNotFound(path.display().to_string()));
368        }
369        let recursive = args
370            .get("recursive")
371            .and_then(|v| v.as_bool())
372            .unwrap_or(false);
373        if path.is_dir() {
374            if recursive {
375                std::fs::remove_dir_all(path)
376                    .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
377            } else {
378                std::fs::remove_dir(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
379            }
380        } else {
381            std::fs::remove_file(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
382        }
383
384        // Chain event: log filesystem removal
385        #[cfg(feature = "exochain")]
386        clawft_core::chain_event::push_chain_event(
387            "wasm_fs",
388            "wasm.fs.remove",
389            Some(serde_json::json!({"path": path_str, "recursive": recursive})),
390        );
391
392        Ok(serde_json::json!({"removed": path_str}))
393    }
394}
395
396/// Built-in `fs.copy` tool.
397pub struct FsCopyTool {
398    spec: BuiltinToolSpec,
399    sandbox: SandboxConfig,
400}
401
402impl FsCopyTool {
403    pub fn new() -> Self {
404        let spec = builtin_tool_catalog()
405            .into_iter()
406            .find(|s| s.name == "fs.copy")
407            .unwrap();
408        Self {
409            spec,
410            sandbox: SandboxConfig::default(),
411        }
412    }
413}
414
415impl BuiltinTool for FsCopyTool {
416    fn name(&self) -> &str {
417        "fs.copy"
418    }
419    fn spec(&self) -> &BuiltinToolSpec {
420        &self.spec
421    }
422    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
423        let src_str = args
424            .get("src")
425            .and_then(|v| v.as_str())
426            .ok_or_else(|| ToolError::InvalidArgs("missing 'src'".into()))?;
427        let dst_str = args
428            .get("dst")
429            .and_then(|v| v.as_str())
430            .ok_or_else(|| ToolError::InvalidArgs("missing 'dst'".into()))?;
431        let src = std::path::Path::new(src_str);
432        let dst = std::path::Path::new(dst_str);
433        if !self.sandbox.is_path_allowed(src) || !self.sandbox.is_path_allowed(dst) {
434            return Err(ToolError::PermissionDenied("path outside sandbox".into()));
435        }
436        if !src.exists() {
437            return Err(ToolError::FileNotFound(src.display().to_string()));
438        }
439        let bytes =
440            std::fs::copy(src, dst).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
441
442        // Chain event: log filesystem copy
443        #[cfg(feature = "exochain")]
444        clawft_core::chain_event::push_chain_event(
445            "wasm_fs",
446            "wasm.fs.copy",
447            Some(serde_json::json!({"src": src_str, "dst": dst_str, "bytes_copied": bytes})),
448        );
449
450        Ok(serde_json::json!({"copied": bytes, "src": src_str, "dst": dst_str}))
451    }
452}
453
454/// Built-in `fs.move` tool.
455pub struct FsMoveTool {
456    spec: BuiltinToolSpec,
457    sandbox: SandboxConfig,
458}
459
460impl FsMoveTool {
461    pub fn new() -> Self {
462        let spec = builtin_tool_catalog()
463            .into_iter()
464            .find(|s| s.name == "fs.move")
465            .unwrap();
466        Self {
467            spec,
468            sandbox: SandboxConfig::default(),
469        }
470    }
471}
472
473impl BuiltinTool for FsMoveTool {
474    fn name(&self) -> &str {
475        "fs.move"
476    }
477    fn spec(&self) -> &BuiltinToolSpec {
478        &self.spec
479    }
480    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
481        let src_str = args
482            .get("src")
483            .and_then(|v| v.as_str())
484            .ok_or_else(|| ToolError::InvalidArgs("missing 'src'".into()))?;
485        let dst_str = args
486            .get("dst")
487            .and_then(|v| v.as_str())
488            .ok_or_else(|| ToolError::InvalidArgs("missing 'dst'".into()))?;
489        let src = std::path::Path::new(src_str);
490        let dst = std::path::Path::new(dst_str);
491        if !self.sandbox.is_path_allowed(src) || !self.sandbox.is_path_allowed(dst) {
492            return Err(ToolError::PermissionDenied("path outside sandbox".into()));
493        }
494        if !src.exists() {
495            return Err(ToolError::FileNotFound(src.display().to_string()));
496        }
497        std::fs::rename(src, dst).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
498
499        // Chain event: log filesystem move
500        #[cfg(feature = "exochain")]
501        clawft_core::chain_event::push_chain_event(
502            "wasm_fs",
503            "wasm.fs.move",
504            Some(serde_json::json!({"src": src_str, "dst": dst_str})),
505        );
506
507        Ok(serde_json::json!({"moved": true, "src": src_str, "dst": dst_str}))
508    }
509}
510
511/// Built-in `fs.stat` tool.
512pub struct FsStatTool {
513    spec: BuiltinToolSpec,
514    sandbox: SandboxConfig,
515}
516
517impl FsStatTool {
518    pub fn new() -> Self {
519        let spec = builtin_tool_catalog()
520            .into_iter()
521            .find(|s| s.name == "fs.stat")
522            .unwrap();
523        Self {
524            spec,
525            sandbox: SandboxConfig::default(),
526        }
527    }
528}
529
530impl BuiltinTool for FsStatTool {
531    fn name(&self) -> &str {
532        "fs.stat"
533    }
534    fn spec(&self) -> &BuiltinToolSpec {
535        &self.spec
536    }
537    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
538        let path_str = args
539            .get("path")
540            .and_then(|v| v.as_str())
541            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
542        let path = std::path::Path::new(path_str);
543        if !self.sandbox.is_path_allowed(path) {
544            return Err(ToolError::PermissionDenied(format!(
545                "path outside sandbox: {}",
546                path.display()
547            )));
548        }
549        let meta =
550            std::fs::metadata(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
551        let modified = meta
552            .modified()
553            .ok()
554            .map(|t| {
555                let dt: DateTime<Utc> = t.into();
556                dt.to_rfc3339()
557            })
558            .unwrap_or_default();
559        Ok(serde_json::json!({
560            "size": meta.len(),
561            "is_file": meta.is_file(),
562            "is_dir": meta.is_dir(),
563            "readonly": meta.permissions().readonly(),
564            "modified": modified,
565        }))
566    }
567}
568
569/// Built-in `fs.exists` tool.
570pub struct FsExistsTool {
571    spec: BuiltinToolSpec,
572}
573
574impl FsExistsTool {
575    pub fn new() -> Self {
576        let spec = builtin_tool_catalog()
577            .into_iter()
578            .find(|s| s.name == "fs.exists")
579            .unwrap();
580        Self { spec }
581    }
582}
583
584impl BuiltinTool for FsExistsTool {
585    fn name(&self) -> &str {
586        "fs.exists"
587    }
588    fn spec(&self) -> &BuiltinToolSpec {
589        &self.spec
590    }
591    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
592        let path_str = args
593            .get("path")
594            .and_then(|v| v.as_str())
595            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
596        let path = std::path::Path::new(path_str);
597        let exists = path.exists();
598        let is_file = path.is_file();
599        let is_dir = path.is_dir();
600        Ok(serde_json::json!({"exists": exists, "is_file": is_file, "is_dir": is_dir}))
601    }
602}
603
604/// Built-in `fs.glob` tool.
605pub struct FsGlobTool {
606    spec: BuiltinToolSpec,
607    sandbox: SandboxConfig,
608}
609
610impl FsGlobTool {
611    pub fn new() -> Self {
612        let spec = builtin_tool_catalog()
613            .into_iter()
614            .find(|s| s.name == "fs.glob")
615            .unwrap();
616        Self {
617            spec,
618            sandbox: SandboxConfig::default(),
619        }
620    }
621}
622
623impl BuiltinTool for FsGlobTool {
624    fn name(&self) -> &str {
625        "fs.glob"
626    }
627    fn spec(&self) -> &BuiltinToolSpec {
628        &self.spec
629    }
630    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
631        let pattern = args
632            .get("pattern")
633            .and_then(|v| v.as_str())
634            .ok_or_else(|| ToolError::InvalidArgs("missing 'pattern'".into()))?;
635        let base_dir = args.get("base_dir").and_then(|v| v.as_str()).unwrap_or(".");
636        let base = std::path::Path::new(base_dir);
637        if !self.sandbox.is_path_allowed(base) {
638            return Err(ToolError::PermissionDenied(
639                "base_dir outside sandbox".into(),
640            ));
641        }
642        // Simple recursive walk with pattern matching
643        let mut matches = Vec::new();
644        fn walk(dir: &std::path::Path, pattern: &str, matches: &mut Vec<String>) {
645            if let Ok(entries) = std::fs::read_dir(dir) {
646                for entry in entries.flatten() {
647                    let path = entry.path();
648                    let name = path
649                        .file_name()
650                        .map(|n| n.to_string_lossy().to_string())
651                        .unwrap_or_default();
652                    if simple_glob_match(pattern, &name) {
653                        matches.push(path.display().to_string());
654                    }
655                    if path.is_dir() {
656                        walk(&path, pattern, matches);
657                    }
658                }
659            }
660        }
661        walk(base, pattern, &mut matches);
662        matches.sort();
663        Ok(serde_json::json!({"matches": matches, "count": matches.len()}))
664    }
665}
666
667/// Simple glob pattern match supporting `*` and `?` wildcards.
668pub(crate) fn simple_glob_match(pattern: &str, text: &str) -> bool {
669    let p: Vec<char> = pattern.chars().collect();
670    let t: Vec<char> = text.chars().collect();
671    simple_glob_match_inner(&p, &t, 0, 0)
672}
673
674fn simple_glob_match_inner(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {
675    if pi == pattern.len() && ti == text.len() {
676        return true;
677    }
678    if pi == pattern.len() {
679        return false;
680    }
681    match pattern[pi] {
682        '*' => {
683            // Match zero or more characters
684            for i in ti..=text.len() {
685                if simple_glob_match_inner(pattern, text, pi + 1, i) {
686                    return true;
687                }
688            }
689            false
690        }
691        '?' => {
692            if ti < text.len() {
693                simple_glob_match_inner(pattern, text, pi + 1, ti + 1)
694            } else {
695                false
696            }
697        }
698        c => {
699            if ti < text.len() && text[ti] == c {
700                simple_glob_match_inner(pattern, text, pi + 1, ti + 1)
701            } else {
702                false
703            }
704        }
705    }
706}