1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, 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 classify(&self, _args: &serde_json::Value) -> ToolClass {
152 ToolClass::READONLY_PARALLEL
153 }
154
155 fn parameters_schema(&self) -> serde_json::Value {
156 json!({
157 "type": "object",
158 "properties": {
159 "file_path": {
160 "type": "string",
161 "description": "The absolute path to the file or directory to read"
162 },
163 "offset": {
164 "type": "number",
165 "description": "The line offset to start reading from. Omit when you want the full file or directory listing."
166 },
167 "limit": {
168 "type": "number",
169 "description": "The maximum number of lines or directory entries to read. Omit for the full result when safe."
170 }
171 },
172 "required": ["file_path"],
173 "additionalProperties": false
174 })
175 }
176
177 async fn invoke(
178 &self,
179 args: serde_json::Value,
180 ctx: ToolCtx,
181 ) -> Result<ToolOutcome, ToolError> {
182 let parsed: ReadArgs = serde_json::from_value(args)
183 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Read args: {}", e)))?;
184
185 let path = Path::new(parsed.file_path.trim());
186 if !path.is_absolute() {
187 return Err(ToolError::InvalidArguments(
188 "file_path must be an absolute path".to_string(),
189 ));
190 }
191 if Self::is_blocked_device_path(path) {
192 return Err(ToolError::InvalidArguments(format!(
193 "Refusing to read blocking or unbounded device path: {}",
194 path.display()
195 )));
196 }
197
198 let metadata = tokio::fs::metadata(path)
199 .await
200 .map_err(|e| ToolError::Execution(format!("Failed to read path: {}", e)))?;
201
202 if metadata.is_dir() {
203 let mut dir = tokio::fs::read_dir(path)
204 .await
205 .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))?;
206 let mut entries = Vec::new();
207 while let Some(entry) = dir
208 .next_entry()
209 .await
210 .map_err(|e| ToolError::Execution(format!("Failed to iterate directory: {}", e)))?
211 {
212 let mut name = entry.file_name().to_string_lossy().to_string();
213 if entry
214 .file_type()
215 .await
216 .map_err(|e| ToolError::Execution(format!("Failed to inspect entry: {}", e)))?
217 .is_dir()
218 {
219 name.push('/');
220 }
221 entries.push(name);
222 }
223 entries.sort();
224
225 let rendered =
226 render_directory_entries(&entries, parsed.offset.unwrap_or(0), parsed.limit);
227 return Ok(ToolOutcome::Completed(ToolResult {
228 success: true,
229 result: rendered,
230 display_preference: Some("Collapsible".to_string()),
231 images: Vec::new(),
232 }));
233 }
234
235 if metadata.len() > MAX_READ_SIZE {
236 return Err(ToolError::Execution(format!(
237 "File is {} bytes, which exceeds the maximum readable size of {} bytes ({} MB). \
238 Use Grep to search within this file instead.",
239 metadata.len(),
240 MAX_READ_SIZE,
241 MAX_READ_SIZE / 1024 / 1024
242 )));
243 }
244
245 let bytes = tokio::fs::read(path)
246 .await
247 .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?;
248
249 if let Some(session_id) = ctx.session_id() {
250 read_tracker::mark_read(session_id, parsed.file_path.trim()).await;
251 }
252
253 if bytes.contains(&0) {
254 return Ok(ToolOutcome::Completed(ToolResult {
255 success: true,
256 result: "[Binary file omitted]".to_string(),
257 display_preference: Some("Collapsible".to_string()),
258 images: Vec::new(),
259 }));
260 }
261
262 let content = String::from_utf8_lossy(&bytes).to_string();
263 let rendered =
264 render_file_with_line_numbers(&content, parsed.offset.unwrap_or(0), parsed.limit);
265
266 Ok(ToolOutcome::Completed(ToolResult {
267 success: true,
268 result: rendered,
269 display_preference: Some("Collapsible".to_string()),
270 images: Vec::new(),
271 }))
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::tools::WriteTool;
279 use serde_json::json;
280
281 #[tokio::test]
282 async fn binary_read_still_marks_file_as_read_for_session_write_gate() {
283 let file = tempfile::NamedTempFile::new().unwrap();
284 tokio::fs::write(file.path(), vec![0_u8, 1, 2, 3])
285 .await
286 .unwrap();
287 let file_path = file.path().to_string_lossy().to_string();
288 let make_ctx = || ToolCtx {
289 session_id: Some(std::sync::Arc::from("session_binary_read")),
290 tool_call_id: std::sync::Arc::from("call_1"),
291 event_tx: None,
292 available_tool_schemas: std::sync::Arc::from(Vec::new()),
293 bypass_permissions: false,
294 auto_approve_permissions: false,
295 plan_read_only: false,
296 can_async_resume: false,
297 async_completion_sink: None,
298 bash_completion_sink: None,
299 };
300
301 let read_tool = ReadTool::new();
302 let read_out = read_tool
303 .invoke(json!({ "file_path": file_path }), make_ctx())
304 .await
305 .unwrap();
306 let ToolOutcome::Completed(read_result) = read_out else {
307 panic!("expected Completed")
308 };
309 assert!(read_result.success);
310 assert!(read_result.result.contains("Binary file omitted"));
311
312 let write_tool = WriteTool::new();
313 let write_out = write_tool
314 .invoke(
315 json!({
316 "file_path": file.path(),
317 "content": "now text"
318 }),
319 make_ctx(),
320 )
321 .await
322 .unwrap();
323 let ToolOutcome::Completed(write_result) = write_out else {
324 panic!("expected Completed")
325 };
326 assert!(write_result.success);
327 }
328
329 #[tokio::test]
330 async fn read_directory_supports_offset_limit_and_marks_subdirs() {
331 let dir = tempfile::tempdir().unwrap();
332 tokio::fs::create_dir_all(dir.path().join("b-dir"))
333 .await
334 .unwrap();
335 tokio::fs::write(dir.path().join("a.txt"), "a")
336 .await
337 .unwrap();
338 tokio::fs::write(dir.path().join("c.txt"), "c")
339 .await
340 .unwrap();
341
342 let tool = ReadTool::new();
343 let out = tool
344 .invoke(
345 json!({
346 "file_path": dir.path(),
347 "offset": 1,
348 "limit": 1
349 }),
350 ToolCtx::none("t"),
351 )
352 .await
353 .unwrap();
354 let ToolOutcome::Completed(result) = out else {
355 panic!("expected Completed")
356 };
357
358 assert!(result.success);
359 assert!(result.result.contains("b-dir/"));
360 assert!(result.result.contains("TRUNCATED"));
361 }
362
363 #[tokio::test]
364 async fn read_file_adds_continuation_hint_when_truncated() {
365 let file = tempfile::NamedTempFile::new().unwrap();
366 tokio::fs::write(file.path(), "l1\nl2\nl3\n").await.unwrap();
367
368 let tool = ReadTool::new();
369 let out = tool
370 .invoke(
371 json!({
372 "file_path": file.path(),
373 "offset": 0,
374 "limit": 1
375 }),
376 ToolCtx::none("t"),
377 )
378 .await
379 .unwrap();
380 let ToolOutcome::Completed(result) = out else {
381 panic!("expected Completed")
382 };
383
384 assert!(result.success);
385 assert!(result.result.contains("l1"));
386 assert!(result.result.contains("Continue with offset=1"));
387 }
388
389 #[tokio::test]
390 async fn read_rejects_blocking_device_paths() {
391 let tool = ReadTool::new();
392 let result = tool
393 .invoke(
394 json!({
395 "file_path": "/dev/stdin"
396 }),
397 ToolCtx::none("t"),
398 )
399 .await;
400
401 let error = result.expect_err("device path should be rejected");
402 assert!(matches!(error, ToolError::InvalidArguments(_)));
403 assert!(error
404 .to_string()
405 .contains("Refusing to read blocking or unbounded device path"));
406 }
407}