Skip to main content

oharness_tools/
fs.rs

1//! `fs` tool kit (ยง7.5). read/write/list scoped to a `Workspace` (or cwd if none).
2//!
3//! All paths resolve relative to the workspace root; absolute paths that escape the
4//! workspace are rejected with `ExecutionError { recoverable: false }`.
5
6use crate::context::ToolContext;
7use crate::toolset::{ToolOutcome, ToolSet};
8use async_trait::async_trait;
9use oharness_core::message::{Content, ToolOutput};
10use oharness_core::ToolSpec;
11use serde::Deserialize;
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15
16const MAX_READ_BYTES: u64 = 1024 * 1024; // 1MiB
17
18/// Bundle of small fs tools: `fs_read`, `fs_write`, `fs_list`.
19pub struct FsToolSet {
20    specs: Vec<ToolSpec>,
21}
22
23impl Default for FsToolSet {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl FsToolSet {
30    pub fn new() -> Self {
31        Self {
32            specs: vec![
33                ToolSpec {
34                    name: "fs_read".to_string(),
35                    description: "Read a UTF-8 text file relative to the workspace root. \
36                                  Returns the file's contents (max 1MiB)."
37                        .to_string(),
38                    input_schema: read_schema(),
39                },
40                ToolSpec {
41                    name: "fs_write".to_string(),
42                    description: "Write UTF-8 text to a file relative to the workspace root. \
43                                  Overwrites if the file exists; creates parent directories \
44                                  as needed."
45                        .to_string(),
46                    input_schema: write_schema(),
47                },
48                ToolSpec {
49                    name: "fs_list".to_string(),
50                    description: "List entries in a directory relative to the workspace root."
51                        .to_string(),
52                    input_schema: list_schema(),
53                },
54            ],
55        }
56    }
57}
58
59#[async_trait]
60impl ToolSet for FsToolSet {
61    fn specs(&self) -> &[ToolSpec] {
62        &self.specs
63    }
64
65    async fn execute(&self, name: &str, input: Value, ctx: &ToolContext) -> ToolOutcome {
66        if ctx.cancellation.is_cancelled() {
67            return ToolOutcome::Cancelled;
68        }
69        let root = ctx
70            .workspace_path()
71            .map(Path::to_path_buf)
72            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
73
74        match name {
75            "fs_read" => do_read(input, &root).await,
76            "fs_write" => do_write(input, &root).await,
77            "fs_list" => do_list(input, &root).await,
78            other => ToolOutcome::error(format!("unknown fs tool `{other}`"), false),
79        }
80    }
81}
82
83async fn do_read(input: Value, root: &Path) -> ToolOutcome {
84    #[derive(Deserialize)]
85    struct ReadInput {
86        path: String,
87    }
88    let parsed: ReadInput = match serde_json::from_value(input) {
89        Ok(v) => v,
90        Err(e) => return ToolOutcome::error(format!("invalid fs_read input: {e}"), false),
91    };
92
93    let resolved = match resolve(root, &parsed.path) {
94        Ok(p) => p,
95        Err(e) => return ToolOutcome::error(e, false),
96    };
97
98    let metadata = match tokio::fs::metadata(&resolved).await {
99        Ok(m) => m,
100        Err(e) => return ToolOutcome::error(format!("fs_read stat: {e}"), true),
101    };
102    if !metadata.is_file() {
103        return ToolOutcome::error(format!("fs_read: `{}` is not a file", parsed.path), false);
104    }
105    if metadata.len() > MAX_READ_BYTES {
106        return ToolOutcome::error(
107            format!(
108                "fs_read: `{}` is {} bytes; max {MAX_READ_BYTES}",
109                parsed.path,
110                metadata.len()
111            ),
112            false,
113        );
114    }
115
116    match tokio::fs::read_to_string(&resolved).await {
117        Ok(contents) => ToolOutcome::Success(ToolOutput {
118            content: vec![Content::Text { text: contents }],
119            truncated: false,
120        }),
121        Err(e) => ToolOutcome::error(format!("fs_read: {e}"), true),
122    }
123}
124
125async fn do_write(input: Value, root: &Path) -> ToolOutcome {
126    #[derive(Deserialize)]
127    struct WriteInput {
128        path: String,
129        content: String,
130    }
131    let parsed: WriteInput = match serde_json::from_value(input) {
132        Ok(v) => v,
133        Err(e) => return ToolOutcome::error(format!("invalid fs_write input: {e}"), false),
134    };
135
136    let resolved = match resolve(root, &parsed.path) {
137        Ok(p) => p,
138        Err(e) => return ToolOutcome::error(e, false),
139    };
140
141    if let Some(parent) = resolved.parent() {
142        if let Err(e) = tokio::fs::create_dir_all(parent).await {
143            return ToolOutcome::error(format!("fs_write mkdir: {e}"), true);
144        }
145    }
146
147    match tokio::fs::write(&resolved, parsed.content.as_bytes()).await {
148        Ok(()) => ToolOutcome::success_text(format!(
149            "wrote {} bytes to {}",
150            parsed.content.len(),
151            parsed.path
152        )),
153        Err(e) => ToolOutcome::error(format!("fs_write: {e}"), true),
154    }
155}
156
157async fn do_list(input: Value, root: &Path) -> ToolOutcome {
158    #[derive(Deserialize)]
159    struct ListInput {
160        #[serde(default = "default_dot")]
161        path: String,
162    }
163    fn default_dot() -> String {
164        ".".to_string()
165    }
166    let parsed: ListInput = match serde_json::from_value(input) {
167        Ok(v) => v,
168        Err(e) => return ToolOutcome::error(format!("invalid fs_list input: {e}"), false),
169    };
170
171    let resolved = match resolve(root, &parsed.path) {
172        Ok(p) => p,
173        Err(e) => return ToolOutcome::error(e, false),
174    };
175
176    let mut entries = match tokio::fs::read_dir(&resolved).await {
177        Ok(r) => r,
178        Err(e) => return ToolOutcome::error(format!("fs_list: {e}"), true),
179    };
180
181    let mut names: Vec<String> = Vec::new();
182    while let Ok(Some(entry)) = entries.next_entry().await {
183        let name = entry.file_name().to_string_lossy().into_owned();
184        let is_dir = entry
185            .file_type()
186            .await
187            .map(|ft| ft.is_dir())
188            .unwrap_or(false);
189        names.push(if is_dir { format!("{name}/") } else { name });
190    }
191    names.sort();
192
193    ToolOutcome::success_text(names.join("\n"))
194}
195
196/// Resolve a user-provided path relative to `root`, rejecting anything that escapes
197/// the workspace (after canonicalization).
198fn resolve(root: &Path, rel: &str) -> Result<PathBuf, String> {
199    let candidate = root.join(rel);
200    // We don't canonicalize (the target may not exist yet for write), so instead
201    // normalize and then check the prefix.
202    let normalized = normalize(&candidate);
203    if !normalized.starts_with(root) {
204        return Err(format!(
205            "path `{rel}` escapes workspace root `{}`",
206            root.display()
207        ));
208    }
209    Ok(normalized)
210}
211
212/// Simple path normalization (no I/O): collapses `..` / `.` components.
213fn normalize(p: &Path) -> PathBuf {
214    let mut out = PathBuf::new();
215    for component in p.components() {
216        match component {
217            std::path::Component::ParentDir => {
218                out.pop();
219            }
220            std::path::Component::CurDir => {}
221            c => out.push(c.as_os_str()),
222        }
223    }
224    out
225}
226
227fn read_schema() -> Value {
228    static S: OnceLock<Value> = OnceLock::new();
229    S.get_or_init(|| {
230        json!({
231            "type": "object",
232            "required": ["path"],
233            "properties": {
234                "path": {"type": "string", "description": "File path relative to workspace root."}
235            },
236            "additionalProperties": false
237        })
238    })
239    .clone()
240}
241
242fn write_schema() -> Value {
243    static S: OnceLock<Value> = OnceLock::new();
244    S.get_or_init(|| {
245        json!({
246            "type": "object",
247            "required": ["path", "content"],
248            "properties": {
249                "path": {"type": "string", "description": "File path relative to workspace root."},
250                "content": {"type": "string", "description": "UTF-8 text to write."}
251            },
252            "additionalProperties": false
253        })
254    })
255    .clone()
256}
257
258fn list_schema() -> Value {
259    static S: OnceLock<Value> = OnceLock::new();
260    S.get_or_init(|| {
261        json!({
262            "type": "object",
263            "properties": {
264                "path": {"type": "string", "description": "Directory path relative to workspace root (default `.`)."}
265            },
266            "additionalProperties": false
267        })
268    })
269    .clone()
270}