Skip to main content

agent_sdk_tools/
environment.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use std::path::{Component, Path, PathBuf};
5
6/// Entry in a directory listing
7#[derive(Clone, Debug, Serialize, Deserialize)]
8pub struct FileEntry {
9    pub name: String,
10    pub path: String,
11    pub is_dir: bool,
12    pub size: Option<u64>,
13}
14
15/// Match result from grep operation
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct GrepMatch {
18    pub path: String,
19    pub line_number: usize,
20    pub line_content: String,
21    pub match_start: usize,
22    pub match_end: usize,
23}
24
25/// Result from command execution
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct ExecResult {
28    pub stdout: String,
29    pub stderr: String,
30    pub exit_code: i32,
31}
32
33impl ExecResult {
34    #[must_use]
35    pub const fn success(&self) -> bool {
36        self.exit_code == 0
37    }
38}
39
40/// Environment abstraction for file and command operations.
41///
42/// The SDK's primitive tools (Read, Write, Grep, Glob, Bash) use this trait
43/// to interact with the underlying filesystem or storage backend.
44///
45/// Implementations:
46/// - `LocalFileSystem` - Standard filesystem (provided by SDK)
47/// - `InMemoryFileSystem` - For testing (provided by SDK)
48/// - Custom backends (S3, Git, iCloud, etc.)
49#[async_trait]
50pub trait Environment: Send + Sync {
51    /// Read file contents as UTF-8 string
52    ///
53    /// # Errors
54    /// Returns an error if the file cannot be read.
55    async fn read_file(&self, path: &str) -> Result<String>;
56
57    /// Read file contents as raw bytes
58    ///
59    /// # Errors
60    /// Returns an error if the file cannot be read.
61    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
62
63    /// Write string content to file (creates or overwrites)
64    ///
65    /// # Errors
66    /// Returns an error if the file cannot be written.
67    async fn write_file(&self, path: &str, content: &str) -> Result<()>;
68
69    /// Write raw bytes to file
70    ///
71    /// # Errors
72    /// Returns an error if the file cannot be written.
73    async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()>;
74
75    /// List directory contents
76    ///
77    /// # Errors
78    /// Returns an error if the directory cannot be read.
79    async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>>;
80
81    /// Check if path exists
82    ///
83    /// # Errors
84    /// Returns an error if existence cannot be determined.
85    async fn exists(&self, path: &str) -> Result<bool>;
86
87    /// Check if path is a directory
88    ///
89    /// # Errors
90    /// Returns an error if the check fails.
91    async fn is_dir(&self, path: &str) -> Result<bool>;
92
93    /// Check if path is a file
94    ///
95    /// # Errors
96    /// Returns an error if the check fails.
97    async fn is_file(&self, path: &str) -> Result<bool>;
98
99    /// Create directory (including parents)
100    ///
101    /// # Errors
102    /// Returns an error if the directory cannot be created.
103    async fn create_dir(&self, path: &str) -> Result<()>;
104
105    /// Delete file
106    ///
107    /// # Errors
108    /// Returns an error if the file cannot be deleted.
109    async fn delete_file(&self, path: &str) -> Result<()>;
110
111    /// Delete directory (must be empty unless recursive)
112    ///
113    /// # Errors
114    /// Returns an error if the directory cannot be deleted.
115    async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()>;
116
117    /// Search for pattern in files (like ripgrep)
118    ///
119    /// # Errors
120    /// Returns an error if the search fails.
121    async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>>;
122
123    /// Find files matching glob pattern
124    ///
125    /// # Errors
126    /// Returns an error if the glob operation fails.
127    async fn glob(&self, pattern: &str) -> Result<Vec<String>>;
128
129    /// Execute a shell command
130    ///
131    /// Not all environments support this. Default implementation returns an error.
132    ///
133    /// # Errors
134    /// Returns an error if command execution is not supported or fails.
135    async fn exec(&self, _command: &str, _timeout_ms: Option<u64>) -> Result<ExecResult> {
136        anyhow::bail!("Command execution not supported in this environment")
137    }
138
139    /// Get the root/working directory for this environment
140    fn root(&self) -> &str;
141
142    /// Resolve a relative path to absolute within this environment.
143    ///
144    /// Normalizes `..` and `.` components to prevent path traversal attacks.
145    fn resolve_path(&self, path: &str) -> String {
146        let joined = if path.starts_with('/') {
147            PathBuf::from(path)
148        } else {
149            PathBuf::from(self.root()).join(path)
150        };
151        normalize_path(&joined)
152    }
153}
154
155/// Lexically normalize a path by resolving `.` and `..` components without
156/// hitting the filesystem.
157///
158/// This prevents path traversal attacks where `../../etc/passwd` could escape
159/// an allowed directory. Unlike `std::fs::canonicalize`, this does not require
160/// the path to exist and does not follow symlinks.
161#[must_use]
162pub fn normalize_path(path: &Path) -> String {
163    normalize_path_buf(path).to_string_lossy().into_owned()
164}
165
166/// Lexically normalize a path, returning a `PathBuf`.
167#[must_use]
168pub fn normalize_path_buf(path: &Path) -> PathBuf {
169    let mut components: Vec<Component<'_>> = Vec::new();
170    for component in path.components() {
171        match component {
172            Component::ParentDir => {
173                // Only pop if we have a normal component to pop (don't pop past root)
174                if matches!(components.last(), Some(Component::Normal(_))) {
175                    components.pop();
176                }
177            }
178            Component::CurDir => {} // skip `.`
179            other => components.push(other),
180        }
181    }
182    if components.is_empty() {
183        PathBuf::from("/")
184    } else {
185        components.iter().collect()
186    }
187}
188
189/// A null environment that rejects all operations.
190/// Useful as a default when no environment is configured.
191pub struct NullEnvironment;
192
193#[async_trait]
194impl Environment for NullEnvironment {
195    async fn read_file(&self, _path: &str) -> Result<String> {
196        anyhow::bail!("No environment configured")
197    }
198
199    async fn read_file_bytes(&self, _path: &str) -> Result<Vec<u8>> {
200        anyhow::bail!("No environment configured")
201    }
202
203    async fn write_file(&self, _path: &str, _content: &str) -> Result<()> {
204        anyhow::bail!("No environment configured")
205    }
206
207    async fn write_file_bytes(&self, _path: &str, _content: &[u8]) -> Result<()> {
208        anyhow::bail!("No environment configured")
209    }
210
211    async fn list_dir(&self, _path: &str) -> Result<Vec<FileEntry>> {
212        anyhow::bail!("No environment configured")
213    }
214
215    async fn exists(&self, _path: &str) -> Result<bool> {
216        anyhow::bail!("No environment configured")
217    }
218
219    async fn is_dir(&self, _path: &str) -> Result<bool> {
220        anyhow::bail!("No environment configured")
221    }
222
223    async fn is_file(&self, _path: &str) -> Result<bool> {
224        anyhow::bail!("No environment configured")
225    }
226
227    async fn create_dir(&self, _path: &str) -> Result<()> {
228        anyhow::bail!("No environment configured")
229    }
230
231    async fn delete_file(&self, _path: &str) -> Result<()> {
232        anyhow::bail!("No environment configured")
233    }
234
235    async fn delete_dir(&self, _path: &str, _recursive: bool) -> Result<()> {
236        anyhow::bail!("No environment configured")
237    }
238
239    async fn grep(&self, _pattern: &str, _path: &str, _recursive: bool) -> Result<Vec<GrepMatch>> {
240        anyhow::bail!("No environment configured")
241    }
242
243    async fn glob(&self, _pattern: &str) -> Result<Vec<String>> {
244        anyhow::bail!("No environment configured")
245    }
246
247    fn root(&self) -> &'static str {
248        "/"
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_normalize_path_resolves_parent_dir() {
258        let path = Path::new("/workspace/src/../../etc/passwd");
259        assert_eq!(normalize_path(path), "/etc/passwd");
260    }
261
262    #[test]
263    fn test_normalize_path_resolves_current_dir() {
264        let path = Path::new("/workspace/./src/./file.rs");
265        assert_eq!(normalize_path(path), "/workspace/src/file.rs");
266    }
267
268    #[test]
269    fn test_normalize_path_does_not_escape_root() {
270        let path = Path::new("/workspace/../../../etc/shadow");
271        assert_eq!(normalize_path(path), "/etc/shadow");
272    }
273
274    #[test]
275    fn test_normalize_path_identity() {
276        let path = Path::new("/workspace/src/main.rs");
277        assert_eq!(normalize_path(path), "/workspace/src/main.rs");
278    }
279
280    #[test]
281    fn test_normalize_path_clamps_at_root() {
282        // Trying to go above root should stop at /
283        let path = Path::new("/a/../../../../z");
284        assert_eq!(normalize_path(path), "/z");
285    }
286
287    #[test]
288    fn test_resolve_path_normalizes_traversal() {
289        let env = NullEnvironment;
290        // NullEnvironment root is "/", so relative paths are joined with "/"
291        let resolved = env.resolve_path("src/../../etc/passwd");
292        assert_eq!(resolved, "/etc/passwd");
293    }
294
295    #[test]
296    fn test_resolve_path_absolute_normalized() {
297        let env = NullEnvironment;
298        let resolved = env.resolve_path("/workspace/src/../../../etc/passwd");
299        assert_eq!(resolved, "/etc/passwd");
300    }
301}