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 = std::fs::metadata(path)
82            .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
92            .get("offset")
93            .and_then(|v| v.as_u64())
94            .unwrap_or(0) as usize;
95        let limit = args
96            .get("limit")
97            .and_then(|v| v.as_u64())
98            .map(|v| v as usize);
99
100        let bytes = std::fs::read(path)
101            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
102
103        let end = match limit {
104            Some(l) => std::cmp::min(offset + l, bytes.len()),
105            None => bytes.len(),
106        };
107        let start = std::cmp::min(offset, bytes.len());
108        let slice = &bytes[start..end];
109
110        let content = String::from_utf8_lossy(slice).into_owned();
111        let modified = metadata
112            .modified()
113            .ok()
114            .map(|t| {
115                let dt: DateTime<Utc> = t.into();
116                dt.to_rfc3339()
117            })
118            .unwrap_or_default();
119
120        Ok(serde_json::json!({
121            "content": content,
122            "size": metadata.len(),
123            "modified": modified,
124        }))
125    }
126}
127
128/// Built-in `fs.write_file` tool.
129pub struct FsWriteFileTool {
130    spec: BuiltinToolSpec,
131    sandbox: SandboxConfig,
132}
133
134impl FsWriteFileTool {
135    pub fn new() -> Self {
136        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.write_file").unwrap();
137        Self { spec, sandbox: SandboxConfig::default() }
138    }
139    pub fn with_sandbox(sandbox: SandboxConfig) -> Self {
140        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.write_file").unwrap();
141        Self { spec, sandbox }
142    }
143}
144
145impl BuiltinTool for FsWriteFileTool {
146    fn name(&self) -> &str { "fs.write_file" }
147    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
148    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
149        let path_str = args.get("path").and_then(|v| v.as_str())
150            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
151        let content = args.get("content").and_then(|v| v.as_str())
152            .ok_or_else(|| ToolError::InvalidArgs("missing 'content'".into()))?;
153        let append = args.get("append").and_then(|v| v.as_bool()).unwrap_or(false);
154        let path = std::path::Path::new(path_str);
155        if !self.sandbox.is_path_allowed(path) {
156            return Err(ToolError::PermissionDenied(format!("path outside sandbox: {}", path.display())));
157        }
158        if append {
159            use std::io::Write;
160            let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)
161                .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
162            f.write_all(content.as_bytes()).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
163        } else {
164            std::fs::write(path, content).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
165        }
166        Ok(serde_json::json!({"written": content.len(), "path": path_str}))
167    }
168}
169
170/// Built-in `fs.read_dir` tool.
171pub struct FsReadDirTool {
172    spec: BuiltinToolSpec,
173    sandbox: SandboxConfig,
174}
175
176impl FsReadDirTool {
177    pub fn new() -> Self {
178        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.read_dir").unwrap();
179        Self { spec, sandbox: SandboxConfig::default() }
180    }
181    pub fn with_sandbox(sandbox: SandboxConfig) -> Self {
182        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.read_dir").unwrap();
183        Self { spec, sandbox }
184    }
185}
186
187impl BuiltinTool for FsReadDirTool {
188    fn name(&self) -> &str { "fs.read_dir" }
189    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
190    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
191        let path_str = args.get("path").and_then(|v| v.as_str())
192            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
193        let path = std::path::Path::new(path_str);
194        if !self.sandbox.is_path_allowed(path) {
195            return Err(ToolError::PermissionDenied(format!("path outside sandbox: {}", path.display())));
196        }
197        if !path.exists() {
198            return Err(ToolError::FileNotFound(path.display().to_string()));
199        }
200        let entries: Vec<serde_json::Value> = std::fs::read_dir(path)
201            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?
202            .filter_map(|e| e.ok())
203            .map(|e| {
204                let ft = e.file_type().ok();
205                serde_json::json!({
206                    "name": e.file_name().to_string_lossy(),
207                    "is_dir": ft.as_ref().map(|t| t.is_dir()).unwrap_or(false),
208                    "is_file": ft.as_ref().map(|t| t.is_file()).unwrap_or(false),
209                })
210            })
211            .collect();
212        Ok(serde_json::json!({"entries": entries, "count": entries.len()}))
213    }
214}
215
216/// Built-in `fs.create_dir` tool.
217pub struct FsCreateDirTool {
218    spec: BuiltinToolSpec,
219    sandbox: SandboxConfig,
220}
221
222impl FsCreateDirTool {
223    pub fn new() -> Self {
224        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.create_dir").unwrap();
225        Self { spec, sandbox: SandboxConfig::default() }
226    }
227}
228
229impl BuiltinTool for FsCreateDirTool {
230    fn name(&self) -> &str { "fs.create_dir" }
231    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
232    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
233        let path_str = args.get("path").and_then(|v| v.as_str())
234            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
235        let path = std::path::Path::new(path_str);
236        if !self.sandbox.is_path_allowed(path) {
237            return Err(ToolError::PermissionDenied(format!("path outside sandbox: {}", path.display())));
238        }
239        let recursive = args.get("recursive").and_then(|v| v.as_bool()).unwrap_or(true);
240        if recursive {
241            std::fs::create_dir_all(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
242        } else {
243            std::fs::create_dir(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
244        }
245        Ok(serde_json::json!({"created": path_str}))
246    }
247}
248
249/// Built-in `fs.remove` tool.
250pub struct FsRemoveTool {
251    spec: BuiltinToolSpec,
252    sandbox: SandboxConfig,
253}
254
255impl FsRemoveTool {
256    pub fn new() -> Self {
257        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.remove").unwrap();
258        Self { spec, sandbox: SandboxConfig::default() }
259    }
260}
261
262impl BuiltinTool for FsRemoveTool {
263    fn name(&self) -> &str { "fs.remove" }
264    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
265    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
266        let path_str = args.get("path").and_then(|v| v.as_str())
267            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
268        let path = std::path::Path::new(path_str);
269        if !self.sandbox.is_path_allowed(path) {
270            return Err(ToolError::PermissionDenied(format!("path outside sandbox: {}", path.display())));
271        }
272        if !path.exists() {
273            return Err(ToolError::FileNotFound(path.display().to_string()));
274        }
275        let recursive = args.get("recursive").and_then(|v| v.as_bool()).unwrap_or(false);
276        if path.is_dir() {
277            if recursive {
278                std::fs::remove_dir_all(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
279            } else {
280                std::fs::remove_dir(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
281            }
282        } else {
283            std::fs::remove_file(path).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
284        }
285        Ok(serde_json::json!({"removed": path_str}))
286    }
287}
288
289/// Built-in `fs.copy` tool.
290pub struct FsCopyTool {
291    spec: BuiltinToolSpec,
292    sandbox: SandboxConfig,
293}
294
295impl FsCopyTool {
296    pub fn new() -> Self {
297        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.copy").unwrap();
298        Self { spec, sandbox: SandboxConfig::default() }
299    }
300}
301
302impl BuiltinTool for FsCopyTool {
303    fn name(&self) -> &str { "fs.copy" }
304    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
305    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
306        let src_str = args.get("src").and_then(|v| v.as_str())
307            .ok_or_else(|| ToolError::InvalidArgs("missing 'src'".into()))?;
308        let dst_str = args.get("dst").and_then(|v| v.as_str())
309            .ok_or_else(|| ToolError::InvalidArgs("missing 'dst'".into()))?;
310        let src = std::path::Path::new(src_str);
311        let dst = std::path::Path::new(dst_str);
312        if !self.sandbox.is_path_allowed(src) || !self.sandbox.is_path_allowed(dst) {
313            return Err(ToolError::PermissionDenied("path outside sandbox".into()));
314        }
315        if !src.exists() {
316            return Err(ToolError::FileNotFound(src.display().to_string()));
317        }
318        let bytes = std::fs::copy(src, dst).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
319        Ok(serde_json::json!({"copied": bytes, "src": src_str, "dst": dst_str}))
320    }
321}
322
323/// Built-in `fs.move` tool.
324pub struct FsMoveTool {
325    spec: BuiltinToolSpec,
326    sandbox: SandboxConfig,
327}
328
329impl FsMoveTool {
330    pub fn new() -> Self {
331        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.move").unwrap();
332        Self { spec, sandbox: SandboxConfig::default() }
333    }
334}
335
336impl BuiltinTool for FsMoveTool {
337    fn name(&self) -> &str { "fs.move" }
338    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
339    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
340        let src_str = args.get("src").and_then(|v| v.as_str())
341            .ok_or_else(|| ToolError::InvalidArgs("missing 'src'".into()))?;
342        let dst_str = args.get("dst").and_then(|v| v.as_str())
343            .ok_or_else(|| ToolError::InvalidArgs("missing 'dst'".into()))?;
344        let src = std::path::Path::new(src_str);
345        let dst = std::path::Path::new(dst_str);
346        if !self.sandbox.is_path_allowed(src) || !self.sandbox.is_path_allowed(dst) {
347            return Err(ToolError::PermissionDenied("path outside sandbox".into()));
348        }
349        if !src.exists() {
350            return Err(ToolError::FileNotFound(src.display().to_string()));
351        }
352        std::fs::rename(src, dst).map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
353        Ok(serde_json::json!({"moved": true, "src": src_str, "dst": dst_str}))
354    }
355}
356
357/// Built-in `fs.stat` tool.
358pub struct FsStatTool {
359    spec: BuiltinToolSpec,
360    sandbox: SandboxConfig,
361}
362
363impl FsStatTool {
364    pub fn new() -> Self {
365        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.stat").unwrap();
366        Self { spec, sandbox: SandboxConfig::default() }
367    }
368}
369
370impl BuiltinTool for FsStatTool {
371    fn name(&self) -> &str { "fs.stat" }
372    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
373    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
374        let path_str = args.get("path").and_then(|v| v.as_str())
375            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
376        let path = std::path::Path::new(path_str);
377        if !self.sandbox.is_path_allowed(path) {
378            return Err(ToolError::PermissionDenied(format!("path outside sandbox: {}", path.display())));
379        }
380        let meta = std::fs::metadata(path)
381            .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
382        let modified = meta.modified().ok().map(|t| {
383            let dt: DateTime<Utc> = t.into();
384            dt.to_rfc3339()
385        }).unwrap_or_default();
386        Ok(serde_json::json!({
387            "size": meta.len(),
388            "is_file": meta.is_file(),
389            "is_dir": meta.is_dir(),
390            "readonly": meta.permissions().readonly(),
391            "modified": modified,
392        }))
393    }
394}
395
396/// Built-in `fs.exists` tool.
397pub struct FsExistsTool {
398    spec: BuiltinToolSpec,
399}
400
401impl FsExistsTool {
402    pub fn new() -> Self {
403        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.exists").unwrap();
404        Self { spec }
405    }
406}
407
408impl BuiltinTool for FsExistsTool {
409    fn name(&self) -> &str { "fs.exists" }
410    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
411    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
412        let path_str = args.get("path").and_then(|v| v.as_str())
413            .ok_or_else(|| ToolError::InvalidArgs("missing 'path'".into()))?;
414        let path = std::path::Path::new(path_str);
415        let exists = path.exists();
416        let is_file = path.is_file();
417        let is_dir = path.is_dir();
418        Ok(serde_json::json!({"exists": exists, "is_file": is_file, "is_dir": is_dir}))
419    }
420}
421
422/// Built-in `fs.glob` tool.
423pub struct FsGlobTool {
424    spec: BuiltinToolSpec,
425    sandbox: SandboxConfig,
426}
427
428impl FsGlobTool {
429    pub fn new() -> Self {
430        let spec = builtin_tool_catalog().into_iter().find(|s| s.name == "fs.glob").unwrap();
431        Self { spec, sandbox: SandboxConfig::default() }
432    }
433}
434
435impl BuiltinTool for FsGlobTool {
436    fn name(&self) -> &str { "fs.glob" }
437    fn spec(&self) -> &BuiltinToolSpec { &self.spec }
438    fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
439        let pattern = args.get("pattern").and_then(|v| v.as_str())
440            .ok_or_else(|| ToolError::InvalidArgs("missing 'pattern'".into()))?;
441        let base_dir = args.get("base_dir").and_then(|v| v.as_str()).unwrap_or(".");
442        let base = std::path::Path::new(base_dir);
443        if !self.sandbox.is_path_allowed(base) {
444            return Err(ToolError::PermissionDenied("base_dir outside sandbox".into()));
445        }
446        // Simple recursive walk with pattern matching
447        let mut matches = Vec::new();
448        fn walk(dir: &std::path::Path, pattern: &str, matches: &mut Vec<String>) {
449            if let Ok(entries) = std::fs::read_dir(dir) {
450                for entry in entries.flatten() {
451                    let path = entry.path();
452                    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
453                    if simple_glob_match(pattern, &name) {
454                        matches.push(path.display().to_string());
455                    }
456                    if path.is_dir() {
457                        walk(&path, pattern, matches);
458                    }
459                }
460            }
461        }
462        walk(base, pattern, &mut matches);
463        matches.sort();
464        Ok(serde_json::json!({"matches": matches, "count": matches.len()}))
465    }
466}
467
468/// Simple glob pattern match supporting `*` and `?` wildcards.
469pub(crate) fn simple_glob_match(pattern: &str, text: &str) -> bool {
470    let p: Vec<char> = pattern.chars().collect();
471    let t: Vec<char> = text.chars().collect();
472    simple_glob_match_inner(&p, &t, 0, 0)
473}
474
475fn simple_glob_match_inner(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {
476    if pi == pattern.len() && ti == text.len() {
477        return true;
478    }
479    if pi == pattern.len() {
480        return false;
481    }
482    match pattern[pi] {
483        '*' => {
484            // Match zero or more characters
485            for i in ti..=text.len() {
486                if simple_glob_match_inner(pattern, text, pi + 1, i) {
487                    return true;
488                }
489            }
490            false
491        }
492        '?' => {
493            if ti < text.len() {
494                simple_glob_match_inner(pattern, text, pi + 1, ti + 1)
495            } else {
496                false
497            }
498        }
499        c => {
500            if ti < text.len() && text[ti] == c {
501                simple_glob_match_inner(pattern, text, pi + 1, ti + 1)
502            } else {
503                false
504            }
505        }
506    }
507}