From 7fc22ce31e7de283c98de3b63aaea449f1ebabfc Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 11 Feb 2026 14:25:28 -0500 Subject: [PATCH] feat: add reader platform extension for sandboxed delegates Read-only filesystem access extension (default_enabled: false) for use with sandboxed delegates processing untrusted content. - Single 'read' tool: view files (with line numbers, view ranges, 256KB limit, binary detection) and list directories (recursive tree, depth control, ignore patterns) - Path restricted: all paths must resolve within working directory, rejects ../ traversal - No write, no shell, no network by construction - Make text_editor and lang modules public in goose-mcp for reuse - Register reader as platform extension in extension.rs Refs: #6193 --- crates/goose-mcp/src/developer/mod.rs | 4 +- crates/goose/src/agents/extension.rs | 16 + crates/goose/src/agents/mod.rs | 1 + crates/goose/src/agents/reader_extension.rs | 322 ++++++++++++++++++++ 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 crates/goose/src/agents/reader_extension.rs diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 282e638c55..aa122c7c28 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -1,9 +1,9 @@ pub mod analyze; mod editor_models; -mod lang; +pub mod lang; pub mod paths; mod shell; -mod text_editor; +pub mod text_editor; pub mod rmcp_developer; diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 3a34617d71..c55f659076 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -2,6 +2,7 @@ use crate::agents::apps_extension; use crate::agents::chatrecall_extension; use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; +use crate::agents::reader_extension; use crate::agents::summon_extension; use crate::agents::todo_extension; use crate::agents::tom_extension; @@ -139,6 +140,21 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + reader_extension::EXTENSION_NAME, + PlatformExtensionDef { + name: reader_extension::EXTENSION_NAME, + display_name: "Reader", + description: + "Read-only filesystem access for sandboxed delegates. View files and list directories within the working directory.", + default_enabled: false, + unprefixed_tools: false, + client_factory: |ctx| { + Box::new(reader_extension::ReaderClient::new(ctx).unwrap()) + }, + }, + ); + map }, ); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 0efc3ec222..7602866e6c 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -15,6 +15,7 @@ pub mod mcp_client; pub mod moim; pub mod platform_tools; pub mod prompt_manager; +pub(crate) mod reader_extension; mod reply_parts; pub mod retry; mod schedule_tool; diff --git a/crates/goose/src/agents/reader_extension.rs b/crates/goose/src/agents/reader_extension.rs new file mode 100644 index 0000000000..b2f61c40ef --- /dev/null +++ b/crates/goose/src/agents/reader_extension.rs @@ -0,0 +1,322 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use anyhow::Result; +use async_trait::async_trait; +use goose_mcp::developer::text_editor; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, + ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations, ToolsCapability, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; + +pub static EXTENSION_NAME: &str = "reader"; +const MAX_FILE_BYTES: u64 = 256 * 1024; +const MAX_DIR_ENTRIES: usize = 2000; +const IGNORED_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + "__pycache__", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", + "dist", + "build", + ".next", + ".nuxt", + ".output", + "coverage", + ".gradle", + ".idea", + ".vs", + ".vscode", + ".eggs", + ".cache", + "vendor", +]; +const IGNORED_FILES: &[&str] = &[".DS_Store", "Thumbs.db"]; +const IGNORED_SUFFIXES: &[&str] = &[".min.js", ".min.css", ".map", ".lock"]; + +#[derive(Debug, Deserialize, JsonSchema)] +struct ReadParams { + path: String, + #[serde(default = "default_depth")] + max_depth: u32, + #[serde(default)] + view_range: Option>, +} +fn default_depth() -> u32 { + 3 +} + +fn resolve_and_validate(raw: &str, wd: &Path) -> std::result::Result { + let target = if Path::new(raw).is_absolute() { + PathBuf::from(raw) + } else { + wd.join(raw) + }; + if target + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err("Path traversal detected: paths cannot contain '..'".into()); + } + let canon = target + .canonicalize() + .map_err(|e| format!("Cannot resolve '{}': {e}", target.display()))?; + let canon_wd = wd + .canonicalize() + .map_err(|e| format!("Cannot resolve working directory: {e}"))?; + if !canon.starts_with(&canon_wd) { + return Err(format!( + "Path outside working directory: {}", + canon_wd.display() + )); + } + Ok(canon) +} + +fn is_binary(path: &Path) -> bool { + let Ok(mut f) = fs::File::open(path) else { + return false; + }; + let mut buf = [0u8; 8192]; + let Ok(n) = f.read(&mut buf) else { + return false; + }; + buf[..n].contains(&0) +} + +fn read_file( + path: &Path, + view_range: Option>, +) -> std::result::Result, String> { + if is_binary(path) { + return Ok(vec![Content::text(format!( + "Skipped binary file: {}", + path.display() + ))]); + } + let mut buf = Vec::new(); + fs::File::open(path) + .map_err(|e| format!("Cannot open '{}': {e}", path.display()))? + .take(MAX_FILE_BYTES) + .read_to_end(&mut buf) + .map_err(|e| format!("Read error: {e}"))?; + let raw = String::from_utf8_lossy(&buf); + let lines: Vec<&str> = raw.lines().collect(); + let total = lines.len(); + let te_range = match view_range.as_deref() { + Some([a, b]) => Some((*a as usize, *b)), + Some(r) => { + return Err(format!( + "view_range must have exactly 2 elements, got {}", + r.len() + )) + } + None => None, + }; + let (s, e) = text_editor::calculate_view_range(te_range, total).map_err(|e| e.message)?; + Ok(vec![Content::text(text_editor::format_file_content( + path, &lines, s, e, te_range, + ))]) +} + +fn should_ignore(name: &str, is_dir: bool) -> bool { + if is_dir { + IGNORED_DIRS.contains(&name) + } else { + IGNORED_FILES.contains(&name) || IGNORED_SUFFIXES.iter().any(|s| name.ends_with(s)) + } +} + +fn read_directory(path: &Path, max_depth: u32) -> Vec { + let mut out = format!("{}\n", path.display()); + let mut c = (0usize, 0usize, 0usize, false); // files, dirs, total, truncated + build_tree(path, 0, max_depth as usize, &mut out, &mut c); + if c.3 { + out.push_str(&format!("\n(truncated at {MAX_DIR_ENTRIES} entries)\n")); + } + out.push_str(&format!("\n{} files, {} directories\n", c.0, c.1)); + vec![Content::text(out)] +} + +fn build_tree( + dir: &Path, + depth: usize, + max_depth: usize, + out: &mut String, + c: &mut (usize, usize, usize, bool), +) { + if c.3 || depth > max_depth { + return; + } + let Ok(rd) = fs::read_dir(dir) else { return }; + let mut entries: Vec = rd.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + let n = entries.len(); + for (i, entry) in entries.iter().enumerate() { + if c.3 { + return; + } + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let pfx = format!( + "{}{}", + "│ ".repeat(depth), + if i == n - 1 { + "└── " + } else { + "├── " + } + ); + if path.is_dir() { + if should_ignore(&name, true) { + continue; + } + c.1 += 1; + c.2 += 1; + if c.2 > MAX_DIR_ENTRIES { + c.3 = true; + return; + } + out.push_str(&format!("{pfx}{name}/\n")); + if depth < max_depth { + build_tree(&path, depth + 1, max_depth, out, c); + } + } else if path.is_file() { + if should_ignore(&name, false) { + continue; + } + c.0 += 1; + c.2 += 1; + if c.2 > MAX_DIR_ENTRIES { + c.3 = true; + return; + } + let sz = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let sz_s = match sz { + 0..=1023 => format!("{sz}B"), + 1024..=1_048_575 => format!("{:.1}KB", sz as f64 / 1024.0), + _ => format!("{:.1}MB", sz as f64 / 1_048_576.0), + }; + out.push_str(&format!("{pfx}{name:<40} {sz_s}\n")); + } + } +} + +pub struct ReaderClient { + info: InitializeResult, +} + +impl ReaderClient { + pub fn new(_ctx: PlatformExtensionContext) -> Result { + Ok(Self { info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { list_changed: Some(false) }), + ..Default::default() + }, + server_info: Implementation { + name: EXTENSION_NAME.into(), + title: Some("Reader".into()), + version: "1.0.0".into(), + ..Default::default() + }, + instructions: Some("Read-only filesystem access. Use `read` to view files or list directories within the working directory.".into()), + }}) + } + + fn get_tools() -> Vec { + let schema = schemars::schema_for!(ReadParams); + let v = serde_json::to_value(schema).expect("schema"); + vec![Tool::new( + "read", + "Read a file or list a directory. Paths resolve against working directory and must stay within it.", + v.as_object().unwrap().clone(), + ).annotate(ToolAnnotations { + title: Some("Read files and directories".into()), + read_only_hint: Some(true), + idempotent_hint: Some(true), + ..Default::default() + })] + } + + fn handle_read(&self, p: ReadParams, wd: &Path) -> std::result::Result, String> { + let path = resolve_and_validate(&p.path, wd)?; + if path.is_dir() { + Ok(read_directory(&path, p.max_depth)) + } else if path.is_file() { + read_file(&path, p.view_range) + } else { + Err(format!("Not a file or directory: {}", path.display())) + } + } +} + +#[async_trait] +impl McpClientTrait for ReaderClient { + async fn list_tools( + &self, + _: &str, + _: Option, + _: CancellationToken, + ) -> std::result::Result { + Ok(ListToolsResult { + tools: Self::get_tools(), + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _: &str, + name: &str, + arguments: Option, + working_dir: Option<&str>, + _: CancellationToken, + ) -> std::result::Result { + if name != "read" { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Unknown tool: {name}" + ))])); + } + let wd = working_dir + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))); + let params: ReadParams = match arguments + .map(|a| serde_json::from_value(serde_json::Value::Object(a))) + .transpose() + { + Ok(Some(p)) => p, + Ok(None) => ReadParams { + path: ".".into(), + max_depth: 3, + view_range: None, + }, + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Invalid parameters: {e}" + ))])) + } + }; + match self.handle_read(params, &wd) { + Ok(c) => Ok(CallToolResult::success(c)), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {e}" + ))])), + } + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } +}