Skip to main content

agent_sdk/
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.
161pub fn normalize_path(path: &Path) -> String {
162    normalize_path_buf(path).to_string_lossy().into_owned()
163}
164
165/// Lexically normalize a path, returning a `PathBuf`.
166pub fn normalize_path_buf(path: &Path) -> PathBuf {
167    let mut components: Vec<Component<'_>> = Vec::new();
168    for component in path.components() {
169        match component {
170            Component::ParentDir => {
171                // Only pop if we have a normal component to pop (don't pop past root)
172                if matches!(components.last(), Some(Component::Normal(_))) {
173                    components.pop();
174                }
175            }
176            Component::CurDir => {} // skip `.`
177            other => components.push(other),
178        }
179    }
180    if components.is_empty() {
181        PathBuf::from("/")
182    } else {
183        components.iter().collect()
184    }
185}
186
187/// A null environment that rejects all operations.
188/// Useful as a default when no environment is configured.
189pub struct NullEnvironment;
190
191#[async_trait]
192impl Environment for NullEnvironment {
193    async fn read_file(&self, _path: &str) -> Result<String> {
194        anyhow::bail!("No environment configured")
195    }
196
197    async fn read_file_bytes(&self, _path: &str) -> Result<Vec<u8>> {
198        anyhow::bail!("No environment configured")
199    }
200
201    async fn write_file(&self, _path: &str, _content: &str) -> Result<()> {
202        anyhow::bail!("No environment configured")
203    }
204
205    async fn write_file_bytes(&self, _path: &str, _content: &[u8]) -> Result<()> {
206        anyhow::bail!("No environment configured")
207    }
208
209    async fn list_dir(&self, _path: &str) -> Result<Vec<FileEntry>> {
210        anyhow::bail!("No environment configured")
211    }
212
213    async fn exists(&self, _path: &str) -> Result<bool> {
214        anyhow::bail!("No environment configured")
215    }
216
217    async fn is_dir(&self, _path: &str) -> Result<bool> {
218        anyhow::bail!("No environment configured")
219    }
220
221    async fn is_file(&self, _path: &str) -> Result<bool> {
222        anyhow::bail!("No environment configured")
223    }
224
225    async fn create_dir(&self, _path: &str) -> Result<()> {
226        anyhow::bail!("No environment configured")
227    }
228
229    async fn delete_file(&self, _path: &str) -> Result<()> {
230        anyhow::bail!("No environment configured")
231    }
232
233    async fn delete_dir(&self, _path: &str, _recursive: bool) -> Result<()> {
234        anyhow::bail!("No environment configured")
235    }
236
237    async fn grep(&self, _pattern: &str, _path: &str, _recursive: bool) -> Result<Vec<GrepMatch>> {
238        anyhow::bail!("No environment configured")
239    }
240
241    async fn glob(&self, _pattern: &str) -> Result<Vec<String>> {
242        anyhow::bail!("No environment configured")
243    }
244
245    fn root(&self) -> &'static str {
246        "/"
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_normalize_path_resolves_parent_dir() {
256        let path = Path::new("/workspace/src/../../etc/passwd");
257        assert_eq!(normalize_path(path), "/etc/passwd");
258    }
259
260    #[test]
261    fn test_normalize_path_resolves_current_dir() {
262        let path = Path::new("/workspace/./src/./file.rs");
263        assert_eq!(normalize_path(path), "/workspace/src/file.rs");
264    }
265
266    #[test]
267    fn test_normalize_path_does_not_escape_root() {
268        let path = Path::new("/workspace/../../../etc/shadow");
269        assert_eq!(normalize_path(path), "/etc/shadow");
270    }
271
272    #[test]
273    fn test_normalize_path_identity() {
274        let path = Path::new("/workspace/src/main.rs");
275        assert_eq!(normalize_path(path), "/workspace/src/main.rs");
276    }
277
278    #[test]
279    fn test_normalize_path_clamps_at_root() {
280        // Trying to go above root should stop at /
281        let path = Path::new("/a/../../../../z");
282        assert_eq!(normalize_path(path), "/z");
283    }
284
285    #[test]
286    fn test_resolve_path_normalizes_traversal() {
287        let env = NullEnvironment;
288        // NullEnvironment root is "/", so relative paths are joined with "/"
289        let resolved = env.resolve_path("src/../../etc/passwd");
290        assert_eq!(resolved, "/etc/passwd");
291    }
292
293    #[test]
294    fn test_resolve_path_absolute_normalized() {
295        let env = NullEnvironment;
296        let resolved = env.resolve_path("/workspace/src/../../../etc/passwd");
297        assert_eq!(resolved, "/etc/passwd");
298    }
299}