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