1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::path::Path;
6
7use super::read_tracker;
8
9const BLOCKED_DEVICE_PATHS: &[&str] = &[
10 "/dev/zero",
11 "/dev/random",
12 "/dev/urandom",
13 "/dev/full",
14 "/dev/stdin",
15 "/dev/tty",
16 "/dev/console",
17 "/dev/stdout",
18 "/dev/stderr",
19 "/dev/fd/0",
20 "/dev/fd/1",
21 "/dev/fd/2",
22];
23
24const MAX_READ_SIZE: u64 = 10 * 1024 * 1024; #[derive(Debug, Deserialize)]
27struct ReadArgs {
28 file_path: String,
29 #[serde(default)]
30 offset: Option<usize>,
31 #[serde(default)]
32 limit: Option<usize>,
33}
34
35pub struct ReadTool;
36
37impl ReadTool {
38 pub fn new() -> Self {
39 Self
40 }
41
42 fn is_blocked_device_path(path: &Path) -> bool {
43 let display = path.to_string_lossy();
44 if BLOCKED_DEVICE_PATHS
45 .iter()
46 .any(|blocked| display == *blocked)
47 {
48 return true;
49 }
50
51 display.starts_with("/proc/")
52 && (display.ends_with("/fd/0")
53 || display.ends_with("/fd/1")
54 || display.ends_with("/fd/2"))
55 }
56}
57
58impl Default for ReadTool {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64fn slice_bounds(total: usize, offset: usize, limit: Option<usize>) -> (usize, usize) {
65 let start = offset.min(total);
66 let end = limit
67 .map(|value| start.saturating_add(value).min(total))
68 .unwrap_or(total);
69 (start, end)
70}
71
72fn continuation_hint(
73 noun: &str,
74 start: usize,
75 end: usize,
76 total: usize,
77 limit: Option<usize>,
78) -> Option<String> {
79 if end >= total {
80 return None;
81 }
82
83 let shown = end.saturating_sub(start);
84 let limit_fragment = match limit {
85 Some(value) => format!(", limit={value}"),
86 None => String::new(),
87 };
88
89 if shown == 0 {
90 return Some(format!(
91 "[TRUNCATED] No {noun} returned. Continue with offset={end}{limit_fragment}"
92 ));
93 }
94
95 Some(format!(
96 "[TRUNCATED] Showing {noun} {first}-{end} of {total}. Continue with offset={end}{limit_fragment}",
97 first = start + 1
98 ))
99}
100
101fn render_file_with_line_numbers(content: &str, offset: usize, limit: Option<usize>) -> String {
102 let lines: Vec<&str> = content.lines().collect();
103 let (start, end) = slice_bounds(lines.len(), offset, limit);
104
105 let mut rendered = lines[start..end]
106 .iter()
107 .enumerate()
108 .map(|(idx, line)| format!("{:>6}\t{}", start + idx + 1, line))
109 .collect::<Vec<_>>()
110 .join("\n");
111
112 if let Some(hint) = continuation_hint("lines", start, end, lines.len(), limit) {
113 if !rendered.is_empty() {
114 rendered.push('\n');
115 }
116 rendered.push_str(&hint);
117 }
118
119 rendered
120}
121
122fn render_directory_entries(entries: &[String], offset: usize, limit: Option<usize>) -> String {
123 let (start, end) = slice_bounds(entries.len(), offset, limit);
124 let mut rendered = entries[start..end]
125 .iter()
126 .enumerate()
127 .map(|(idx, entry)| format!("{:>6}\t{}", start + idx + 1, entry))
128 .collect::<Vec<_>>()
129 .join("\n");
130
131 if let Some(hint) = continuation_hint("entries", start, end, entries.len(), limit) {
132 if !rendered.is_empty() {
133 rendered.push('\n');
134 }
135 rendered.push_str(&hint);
136 }
137
138 rendered
139}
140
141#[async_trait]
142impl Tool for ReadTool {
143 fn name(&self) -> &str {
144 "Read"
145 }
146
147 fn description(&self) -> &str {
148 "Read a local file or directory with line-numbered output (supports offset/limit). Use this before Edit/Write on existing files. Safe for text files and directories; binary files are omitted and blocking device paths are rejected."
149 }
150
151 fn mutability(&self) -> crate::ToolMutability {
152 crate::ToolMutability::ReadOnly
153 }
154
155 fn concurrency_safe(&self) -> bool {
156 true
157 }
158
159 fn parameters_schema(&self) -> serde_json::Value {
160 json!({
161 "type": "object",
162 "properties": {
163 "file_path": {
164 "type": "string",
165 "description": "The absolute path to the file or directory to read"
166 },
167 "offset": {
168 "type": "number",
169 "description": "The line offset to start reading from. Omit when you want the full file or directory listing."
170 },
171 "limit": {
172 "type": "number",
173 "description": "The maximum number of lines or directory entries to read. Omit for the full result when safe."
174 }
175 },
176 "required": ["file_path"],
177 "additionalProperties": false
178 })
179 }
180
181 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
182 self.execute_with_context(args, ToolExecutionContext::none("Read"))
183 .await
184 }
185
186 async fn execute_with_context(
187 &self,
188 args: serde_json::Value,
189 ctx: ToolExecutionContext<'_>,
190 ) -> Result<ToolResult, ToolError> {
191 let parsed: ReadArgs = serde_json::from_value(args)
192 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Read args: {}", e)))?;
193
194 let path = Path::new(parsed.file_path.trim());
195 if !path.is_absolute() {
196 return Err(ToolError::InvalidArguments(
197 "file_path must be an absolute path".to_string(),
198 ));
199 }
200 if Self::is_blocked_device_path(path) {
201 return Err(ToolError::InvalidArguments(format!(
202 "Refusing to read blocking or unbounded device path: {}",
203 path.display()
204 )));
205 }
206
207 let metadata = tokio::fs::metadata(path)
208 .await
209 .map_err(|e| ToolError::Execution(format!("Failed to read path: {}", e)))?;
210
211 if metadata.is_dir() {
212 let mut dir = tokio::fs::read_dir(path)
213 .await
214 .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))?;
215 let mut entries = Vec::new();
216 while let Some(entry) = dir
217 .next_entry()
218 .await
219 .map_err(|e| ToolError::Execution(format!("Failed to iterate directory: {}", e)))?
220 {
221 let mut name = entry.file_name().to_string_lossy().to_string();
222 if entry
223 .file_type()
224 .await
225 .map_err(|e| ToolError::Execution(format!("Failed to inspect entry: {}", e)))?
226 .is_dir()
227 {
228 name.push('/');
229 }
230 entries.push(name);
231 }
232 entries.sort();
233
234 let rendered =
235 render_directory_entries(&entries, parsed.offset.unwrap_or(0), parsed.limit);
236 return Ok(ToolResult {
237 success: true,
238 result: rendered,
239 display_preference: Some("Collapsible".to_string()),
240 images: Vec::new(),
241 });
242 }
243
244 if metadata.len() > MAX_READ_SIZE {
245 return Err(ToolError::Execution(format!(
246 "File is {} bytes, which exceeds the maximum readable size of {} bytes ({} MB). \
247 Use Grep to search within this file instead.",
248 metadata.len(),
249 MAX_READ_SIZE,
250 MAX_READ_SIZE / 1024 / 1024
251 )));
252 }
253
254 let bytes = tokio::fs::read(path)
255 .await
256 .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?;
257
258 if let Some(session_id) = ctx.session_id {
259 read_tracker::mark_read(session_id, parsed.file_path.trim()).await;
260 }
261
262 if bytes.contains(&0) {
263 return Ok(ToolResult {
264 success: true,
265 result: "[Binary file omitted]".to_string(),
266 display_preference: Some("Collapsible".to_string()),
267 images: Vec::new(),
268 });
269 }
270
271 let content = String::from_utf8_lossy(&bytes).to_string();
272 let rendered =
273 render_file_with_line_numbers(&content, parsed.offset.unwrap_or(0), parsed.limit);
274
275 Ok(ToolResult {
276 success: true,
277 result: rendered,
278 display_preference: Some("Collapsible".to_string()),
279 images: Vec::new(),
280 })
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::tools::WriteTool;
288 use serde_json::json;
289
290 #[tokio::test]
291 async fn binary_read_still_marks_file_as_read_for_session_write_gate() {
292 let file = tempfile::NamedTempFile::new().unwrap();
293 tokio::fs::write(file.path(), vec![0_u8, 1, 2, 3])
294 .await
295 .unwrap();
296 let file_path = file.path().to_string_lossy().to_string();
297 let ctx = ToolExecutionContext {
298 session_id: Some("session_binary_read"),
299 tool_call_id: "call_1",
300 event_tx: None,
301 available_tool_schemas: None,
302 bypass_permissions: false,
303 can_async_resume: false,
304 };
305
306 let read_tool = ReadTool::new();
307 let read_result = read_tool
308 .execute_with_context(json!({ "file_path": file_path }), ctx)
309 .await
310 .unwrap();
311 assert!(read_result.success);
312 assert!(read_result.result.contains("Binary file omitted"));
313
314 let write_tool = WriteTool::new();
315 let write_result = write_tool
316 .execute_with_context(
317 json!({
318 "file_path": file.path(),
319 "content": "now text"
320 }),
321 ctx,
322 )
323 .await
324 .unwrap();
325 assert!(write_result.success);
326 }
327
328 #[tokio::test]
329 async fn read_directory_supports_offset_limit_and_marks_subdirs() {
330 let dir = tempfile::tempdir().unwrap();
331 tokio::fs::create_dir_all(dir.path().join("b-dir"))
332 .await
333 .unwrap();
334 tokio::fs::write(dir.path().join("a.txt"), "a")
335 .await
336 .unwrap();
337 tokio::fs::write(dir.path().join("c.txt"), "c")
338 .await
339 .unwrap();
340
341 let tool = ReadTool::new();
342 let result = tool
343 .execute(json!({
344 "file_path": dir.path(),
345 "offset": 1,
346 "limit": 1
347 }))
348 .await
349 .unwrap();
350
351 assert!(result.success);
352 assert!(result.result.contains("b-dir/"));
353 assert!(result.result.contains("TRUNCATED"));
354 }
355
356 #[tokio::test]
357 async fn read_file_adds_continuation_hint_when_truncated() {
358 let file = tempfile::NamedTempFile::new().unwrap();
359 tokio::fs::write(file.path(), "l1\nl2\nl3\n").await.unwrap();
360
361 let tool = ReadTool::new();
362 let result = tool
363 .execute(json!({
364 "file_path": file.path(),
365 "offset": 0,
366 "limit": 1
367 }))
368 .await
369 .unwrap();
370
371 assert!(result.success);
372 assert!(result.result.contains("l1"));
373 assert!(result.result.contains("Continue with offset=1"));
374 }
375
376 #[tokio::test]
377 async fn read_rejects_blocking_device_paths() {
378 let tool = ReadTool::new();
379 let result = tool
380 .execute(json!({
381 "file_path": "/dev/stdin"
382 }))
383 .await;
384
385 let error = result.expect_err("device path should be rejected");
386 assert!(matches!(error, ToolError::InvalidArguments(_)));
387 assert!(error
388 .to_string()
389 .contains("Refusing to read blocking or unbounded device path"));
390 }
391}