agent_sdk_tools/
environment.rs1use anyhow::Result;
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4use std::path::{Component, Path, PathBuf};
5
6#[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#[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#[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#[async_trait]
50pub trait Environment: Send + Sync {
51 async fn read_file(&self, path: &str) -> Result<String>;
56
57 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
62
63 async fn write_file(&self, path: &str, content: &str) -> Result<()>;
68
69 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()>;
74
75 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>>;
80
81 async fn exists(&self, path: &str) -> Result<bool>;
86
87 async fn is_dir(&self, path: &str) -> Result<bool>;
92
93 async fn is_file(&self, path: &str) -> Result<bool>;
98
99 async fn create_dir(&self, path: &str) -> Result<()>;
104
105 async fn delete_file(&self, path: &str) -> Result<()>;
110
111 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()>;
116
117 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>>;
122
123 async fn glob(&self, pattern: &str) -> Result<Vec<String>>;
128
129 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 fn root(&self) -> &str;
141
142 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#[must_use]
162pub fn normalize_path(path: &Path) -> String {
163 normalize_path_buf(path).to_string_lossy().into_owned()
164}
165
166#[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 if matches!(components.last(), Some(Component::Normal(_))) {
175 components.pop();
176 }
177 }
178 Component::CurDir => {} other => components.push(other),
180 }
181 }
182 if components.is_empty() {
183 PathBuf::from("/")
184 } else {
185 components.iter().collect()
186 }
187}
188
189pub 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 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 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}