1use agnt_core::Tool;
12use serde_json::{json, Value};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use crate::sandbox::FilesystemRoot;
18
19const READ_FILE_MAX: usize = 256 * 1024;
24
25pub struct ReadFile {
31 sandbox: Option<Arc<FilesystemRoot>>,
32}
33
34impl Default for ReadFile {
35 fn default() -> Self { Self::new() }
36}
37
38impl ReadFile {
39 pub fn new() -> Self { Self { sandbox: None } }
42
43 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
46 Self { sandbox: Some(sandbox) }
47 }
48}
49
50fn resolve_path(sandbox: &Option<Arc<FilesystemRoot>>, input: &str) -> Result<PathBuf, String> {
51 match sandbox {
52 Some(s) => s.resolve(input),
53 None => Ok(PathBuf::from(input)),
54 }
55}
56
57impl Tool for ReadFile {
58 fn name(&self) -> &str { "read_file" }
59 fn description(&self) -> &str {
60 "Read a UTF-8 text file and return its contents. Truncated at 256KB. Prefer this over 'shell cat' — it is deterministic and cheaper."
61 }
62 fn schema(&self) -> Value {
63 json!({
64 "type": "object",
65 "properties": {
66 "path": { "type": "string", "description": "file path (must be under the agent sandbox root if one is configured)" }
67 },
68 "required": ["path"]
69 })
70 }
71 fn call(&self, args: Value) -> Result<String, String> {
72 let path = args["path"].as_str().ok_or("missing path")?;
73 let resolved = resolve_path(&self.sandbox, path)?;
74 let content = fs::read_to_string(&resolved)
75 .map_err(|e| format!("read {}: {}", resolved.display(), e))?;
76 if content.len() <= READ_FILE_MAX {
77 return Ok(content);
78 }
79 let mut cut = READ_FILE_MAX;
80 while cut > 0 && !content.is_char_boundary(cut) {
81 cut -= 1;
82 }
83 let mut out = content[..cut].to_string();
84 out.push_str(&format!(
85 "\n...(truncated at {} bytes; file is {} bytes total)",
86 cut,
87 content.len()
88 ));
89 Ok(out)
90 }
91}
92
93pub struct EditFile {
119 sandbox: Option<Arc<FilesystemRoot>>,
120}
121
122impl Default for EditFile {
123 fn default() -> Self { Self::new() }
124}
125
126impl EditFile {
127 pub fn new() -> Self { Self { sandbox: None } }
128 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
129 Self { sandbox: Some(sandbox) }
130 }
131}
132
133impl Tool for EditFile {
134 fn name(&self) -> &str { "edit_file" }
135 fn description(&self) -> &str {
136 "Targeted file edit. Replaces one exact occurrence of 'old' with 'new' in the file. Fails if 'old' is not found or appears more than once — in that case pass more surrounding context in 'old' to make it unique. Prefer this over write_file when changing a small part of an existing file."
137 }
138 fn schema(&self) -> Value {
139 json!({
140 "type": "object",
141 "properties": {
142 "path": { "type": "string" },
143 "old": { "type": "string", "description": "exact text to find (must be unique in the file)" },
144 "new": { "type": "string", "description": "replacement text" }
145 },
146 "required": ["path", "old", "new"]
147 })
148 }
149 fn call(&self, args: Value) -> Result<String, String> {
150 use fs2::FileExt;
151 use std::io::Write;
152
153 let path = args["path"].as_str().ok_or("missing path")?;
154 let old = args["old"].as_str().ok_or("missing old")?;
155 let new_s = args["new"].as_str().ok_or("missing new")?;
156 if old.is_empty() {
157 return Err("'old' must not be empty".into());
158 }
159
160 let resolved = resolve_path(&self.sandbox, path)?;
161
162 let lock_name = format!(
168 ".{}.agnt-edit.lock",
169 resolved
170 .file_name()
171 .and_then(|s| s.to_str())
172 .unwrap_or("edit")
173 );
174 let lock_path = resolved
175 .parent()
176 .map(|p| p.join(&lock_name))
177 .unwrap_or_else(|| PathBuf::from(&lock_name));
178
179 let lock_file = std::fs::OpenOptions::new()
180 .create(true)
181 .read(true)
182 .write(true)
183 .open(&lock_path)
184 .map_err(|e| format!("lock open {}: {}", lock_path.display(), e))?;
185
186 lock_file
187 .lock_exclusive()
188 .map_err(|e| format!("lock {}: {}", lock_path.display(), e))?;
189
190 let perform = || -> Result<(String, String), String> {
194 let content = std::fs::read_to_string(&resolved)
195 .map_err(|e| format!("read {}: {}", resolved.display(), e))?;
196 let count = content.matches(old).count();
197 if count == 0 {
198 return Err(format!("'old' not found in {}", resolved.display()));
199 }
200 if count > 1 {
201 return Err(format!(
202 "'old' appears {} times in {}; pass more surrounding context to make it unique",
203 count,
204 resolved.display()
205 ));
206 }
207 let updated = content.replacen(old, new_s, 1);
208
209 let mut tmp = resolved.clone();
211 let tmp_name = format!(
212 "{}.agnt-edit-tmp.{}.{:?}",
213 resolved
214 .file_name()
215 .and_then(|s| s.to_str())
216 .unwrap_or("edit"),
217 std::process::id(),
218 std::thread::current().id()
219 );
220 tmp.set_file_name(tmp_name);
221 {
222 let mut tmpf = std::fs::OpenOptions::new()
223 .write(true)
224 .create(true)
225 .truncate(true)
226 .open(&tmp)
227 .map_err(|e| format!("tmp open {}: {}", tmp.display(), e))?;
228 tmpf.write_all(updated.as_bytes())
229 .map_err(|e| format!("tmp write: {}", e))?;
230 tmpf.sync_all().map_err(|e| format!("tmp sync: {}", e))?;
231 }
232 std::fs::rename(&tmp, &resolved)
233 .map_err(|e| format!("rename {} -> {}: {}", tmp.display(), resolved.display(), e))?;
234
235 Ok((content, updated))
236 };
237
238 let res = perform();
239 let _ = lock_file.unlock();
241 drop(lock_file);
242
243 let (before, after) = res?;
244 Ok(format!(
245 "edited {} ({} bytes → {} bytes)",
246 resolved.display(),
247 before.len(),
248 after.len()
249 ))
250 }
251}
252
253pub struct WriteFile {
258 sandbox: Option<Arc<FilesystemRoot>>,
259}
260
261impl Default for WriteFile {
262 fn default() -> Self { Self::new() }
263}
264
265impl WriteFile {
266 pub fn new() -> Self { Self { sandbox: None } }
267 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
268 Self { sandbox: Some(sandbox) }
269 }
270}
271
272impl Tool for WriteFile {
273 fn name(&self) -> &str { "write_file" }
274 fn description(&self) -> &str { "Write UTF-8 content to a file, creating or overwriting it." }
275 fn schema(&self) -> Value {
276 json!({
277 "type": "object",
278 "properties": {
279 "path": { "type": "string" },
280 "content": { "type": "string" }
281 },
282 "required": ["path", "content"]
283 })
284 }
285 fn call(&self, args: Value) -> Result<String, String> {
286 let path = args["path"].as_str().ok_or("missing path")?;
287 let content = args["content"].as_str().ok_or("missing content")?;
288 let resolved = resolve_path(&self.sandbox, path)?;
289 fs::write(&resolved, content)
290 .map_err(|e| format!("write {}: {}", resolved.display(), e))?;
291 Ok(format!("wrote {} bytes to {}", content.len(), resolved.display()))
292 }
293}
294
295pub struct ListDir {
300 sandbox: Option<Arc<FilesystemRoot>>,
301}
302
303impl Default for ListDir {
304 fn default() -> Self { Self::new() }
305}
306
307impl ListDir {
308 pub fn new() -> Self { Self { sandbox: None } }
309 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
310 Self { sandbox: Some(sandbox) }
311 }
312}
313
314impl Tool for ListDir {
315 fn name(&self) -> &str { "list_dir" }
316 fn description(&self) -> &str {
317 "List a directory. One entry per line as 'TYPE NAME' where TYPE is F (file), D (dir), or L (symlink)."
318 }
319 fn schema(&self) -> Value {
320 json!({
321 "type": "object",
322 "properties": {
323 "path": { "type": "string" }
324 },
325 "required": ["path"]
326 })
327 }
328 fn call(&self, args: Value) -> Result<String, String> {
329 let path = args["path"].as_str().ok_or("missing path")?;
330 let resolved = resolve_path(&self.sandbox, path)?;
331 let mut out = String::new();
332 for entry in fs::read_dir(&resolved)
333 .map_err(|e| format!("read_dir {}: {}", resolved.display(), e))?
334 {
335 let e = entry.map_err(|e| e.to_string())?;
336 let ft = e.file_type().map_err(|e| e.to_string())?;
337 let tag = if ft.is_dir() { 'D' } else if ft.is_symlink() { 'L' } else { 'F' };
338 out.push_str(&format!("{} {}\n", tag, e.file_name().to_string_lossy()));
339 }
340 Ok(out)
341 }
342}
343
344#[cfg(feature = "shell")]
382pub struct Shell {
383 allowed_argv0: Vec<String>,
384 cwd: PathBuf,
385 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
389 bwrap: Option<BwrapConfig>,
390}
391
392#[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
403#[derive(Debug, Clone)]
404pub struct BwrapConfig {
405 pub share_net: bool,
410}
411
412#[cfg(feature = "shell")]
413impl Shell {
414 pub fn new_sandboxed(allowed_argv0: Vec<String>, cwd: PathBuf) -> Self {
428 Self {
429 allowed_argv0,
430 cwd,
431 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
432 bwrap: None,
433 }
434 }
435
436 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
452 pub fn new_bwrap(
453 allowed_argv0: Vec<String>,
454 cwd: PathBuf,
455 share_net: bool,
456 ) -> Result<Self, String> {
457 let probe = std::process::Command::new("bwrap")
459 .arg("--version")
460 .output()
461 .map_err(|e| format!("bwrap not available: {}", e))?;
462 if !probe.status.success() {
463 return Err(format!(
464 "bwrap --version exited {}",
465 probe.status.code().unwrap_or(-1)
466 ));
467 }
468 Ok(Self {
469 allowed_argv0,
470 cwd,
471 bwrap: Some(BwrapConfig { share_net }),
472 })
473 }
474
475 #[cfg(all(feature = "bwrap-shell", not(target_os = "linux")))]
479 pub fn new_bwrap(
480 _allowed_argv0: Vec<String>,
481 _cwd: PathBuf,
482 _share_net: bool,
483 ) -> Result<Self, String> {
484 Err("bwrap sandbox is Linux-only".into())
485 }
486
487 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
491 fn build_bwrap_argv(
492 cfg: &BwrapConfig,
493 cwd: &std::path::Path,
494 argv: &[String],
495 ) -> Vec<String> {
496 let cwd_str = cwd.to_string_lossy().into_owned();
497 let mut out: Vec<String> = vec![
498 "--ro-bind".into(), "/usr".into(), "/usr".into(),
499 "--ro-bind".into(), "/bin".into(), "/bin".into(),
500 "--ro-bind-try".into(), "/lib".into(), "/lib".into(),
501 "--ro-bind-try".into(), "/lib64".into(), "/lib64".into(),
502 "--ro-bind-try".into(), "/etc".into(), "/etc".into(),
503 "--bind".into(), cwd_str.clone(), cwd_str.clone(),
504 "--tmpfs".into(), "/tmp".into(),
505 "--proc".into(), "/proc".into(),
506 "--dev".into(), "/dev".into(),
507 "--unshare-all".into(),
508 ];
509 if cfg.share_net {
510 out.push("--share-net".into());
511 }
512 out.push("--die-with-parent".into());
513 out.push("--chdir".into());
514 out.push(cwd_str);
515 out.push("--".into());
516 out.extend(argv.iter().cloned());
517 out
518 }
519}
520
521#[cfg(feature = "shell")]
522const SHELL_FORBIDDEN_CHARS: &[char] =
523 &['$', '`', '|', ';', '&', '>', '<', '(', ')', '\n'];
524
525#[cfg(feature = "shell")]
526impl Tool for Shell {
527 fn name(&self) -> &str { "shell" }
528 fn description(&self) -> &str {
529 "Run a program with arguments. The command is parsed with shell-words; argv[0] must be in the caller's allowlist; no sh -c, no command substitution, no pipes. Prefer specialized tools (read_file, grep, glob, fetch) over this."
530 }
531 fn schema(&self) -> Value {
532 json!({
533 "type": "object",
534 "properties": {
535 "cmd": { "type": "string", "description": "command line (e.g. 'git status' or 'cargo build --release')" }
536 },
537 "required": ["cmd"]
538 })
539 }
540 fn call(&self, args: Value) -> Result<String, String> {
541 let cmd = args["cmd"].as_str().ok_or("missing cmd")?;
542 let argv = shell_words::split(cmd)
543 .map_err(|e| format!("shell parse: {}", e))?;
544 if argv.is_empty() {
545 return Err("empty command".into());
546 }
547 for tok in &argv {
548 if let Some(bad) = tok.chars().find(|c| SHELL_FORBIDDEN_CHARS.contains(c)) {
549 return Err(format!(
550 "token contains forbidden character {:?}: {}",
551 bad, tok
552 ));
553 }
554 }
555 let argv0 = &argv[0];
556 if !self.allowed_argv0.iter().any(|a| a == argv0) {
557 return Err(format!(
558 "argv[0] {:?} not in allowlist {:?}",
559 argv0, self.allowed_argv0
560 ));
561 }
562
563 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
566 let out = if let Some(cfg) = &self.bwrap {
567 let bwrap_argv = Self::build_bwrap_argv(cfg, &self.cwd, &argv);
568 std::process::Command::new("bwrap")
569 .args(&bwrap_argv)
570 .output()
571 .map_err(|e| format!("bwrap spawn: {}", e))?
572 } else {
573 std::process::Command::new(argv0)
574 .args(&argv[1..])
575 .current_dir(&self.cwd)
576 .output()
577 .map_err(|e| format!("spawn: {}", e))?
578 };
579
580 #[cfg(not(all(feature = "bwrap-shell", target_os = "linux")))]
581 let out = std::process::Command::new(argv0)
582 .args(&argv[1..])
583 .current_dir(&self.cwd)
584 .output()
585 .map_err(|e| format!("spawn: {}", e))?;
586 let status = out
587 .status
588 .code()
589 .map(|c| c.to_string())
590 .unwrap_or_else(|| "signal".into());
591 Ok(format!(
592 "exit: {}\n--- stdout ---\n{}--- stderr ---\n{}",
593 status,
594 String::from_utf8_lossy(&out.stdout),
595 String::from_utf8_lossy(&out.stderr),
596 ))
597 }
598}
599
600pub struct Glob {
605 sandbox: Option<Arc<FilesystemRoot>>,
606}
607
608impl Default for Glob {
609 fn default() -> Self { Self::new() }
610}
611
612impl Glob {
613 pub fn new() -> Self { Self { sandbox: None } }
614 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
615 Self { sandbox: Some(sandbox) }
616 }
617}
618
619impl Tool for Glob {
620 fn name(&self) -> &str { "glob" }
621 fn description(&self) -> &str {
622 "Find files matching a shell-style glob pattern (e.g. 'src/**/*.rs', '**/Cargo.toml'). Returns one path per line. Prefer this over 'shell find' — it is faster, portable across OSes, and has no command-injection surface."
623 }
624 fn schema(&self) -> Value {
625 json!({
626 "type": "object",
627 "properties": {
628 "pattern": { "type": "string", "description": "glob pattern (must be relative to the sandbox root when sandboxed)" }
629 },
630 "required": ["pattern"]
631 })
632 }
633 fn call(&self, args: Value) -> Result<String, String> {
634 let pattern = args["pattern"].as_str().ok_or("missing pattern")?;
635
636 let (effective_pattern, root_strip): (String, Option<PathBuf>) = match &self.sandbox {
640 Some(s) => {
641 if Path::new(pattern).is_absolute() {
642 return Err(format!(
643 "glob pattern must be relative when sandboxed: {}",
644 pattern
645 ));
646 }
647 if pattern.split('/').any(|seg| seg == "..") {
648 return Err(format!("glob pattern contains '..': {}", pattern));
649 }
650 let joined = s.root().join(pattern);
651 let eff = joined.to_string_lossy().into_owned();
652 (eff, Some(s.root().to_path_buf()))
653 }
654 None => (pattern.to_string(), None),
655 };
656
657 let mut out = String::new();
658 let mut count = 0usize;
659 for entry in glob::glob(&effective_pattern).map_err(|e| format!("glob: {}", e))? {
660 let p = match entry {
661 Ok(p) => p,
662 Err(_) => continue,
663 };
664 if let Some(root) = &root_strip {
667 if let Ok(canonical) = std::fs::canonicalize(&p) {
668 if !canonical.starts_with(root) {
669 continue;
670 }
671 }
672 }
673 out.push_str(&p.to_string_lossy());
674 out.push('\n');
675 count += 1;
676 if count >= 2000 {
677 out.push_str("(truncated at 2000)\n");
678 break;
679 }
680 }
681 if out.is_empty() {
682 Ok("(no matches)".into())
683 } else {
684 Ok(out)
685 }
686 }
687}
688
689pub struct Grep {
694 sandbox: Option<Arc<FilesystemRoot>>,
695}
696
697impl Default for Grep {
698 fn default() -> Self { Self::new() }
699}
700
701impl Grep {
702 pub fn new() -> Self { Self { sandbox: None } }
703 pub fn with_sandbox(sandbox: Arc<FilesystemRoot>) -> Self {
704 Self { sandbox: Some(sandbox) }
705 }
706}
707
708impl Tool for Grep {
709 fn name(&self) -> &str { "grep" }
710 fn description(&self) -> &str {
711 "Search text files under a directory for a regex pattern. Returns 'path:line:text' per match. Optional 'ext' filter (e.g. 'rs', 'md'). Prefer this over 'shell grep' — it is native, typically under 1ms for a source tree, and avoids quoting pitfalls."
712 }
713 fn schema(&self) -> Value {
714 json!({
715 "type": "object",
716 "properties": {
717 "pattern": { "type": "string", "description": "regex pattern" },
718 "path": { "type": "string", "description": "root directory to walk" },
719 "ext": { "type": "string", "description": "optional file extension filter without dot" }
720 },
721 "required": ["pattern", "path"]
722 })
723 }
724 fn call(&self, args: Value) -> Result<String, String> {
725 let pattern = args["pattern"].as_str().ok_or("missing pattern")?;
726 let path = args["path"].as_str().ok_or("missing path")?;
727 let ext = args["ext"].as_str();
728 let resolved = resolve_path(&self.sandbox, path)?;
729 let re = regex::Regex::new(pattern).map_err(|e| format!("regex: {}", e))?;
730 let mut out = String::new();
731 let mut count = 0usize;
732 for entry in walkdir::WalkDir::new(&resolved)
733 .into_iter()
734 .filter_map(|e| e.ok())
735 {
736 if !entry.file_type().is_file() { continue; }
737 if let Some(e) = ext {
738 if entry.path().extension().and_then(|s| s.to_str()) != Some(e) { continue; }
739 }
740 if let Some(sbx) = &self.sandbox {
742 if let Ok(canonical) = std::fs::canonicalize(entry.path()) {
743 if !canonical.starts_with(sbx.root()) {
744 continue;
745 }
746 }
747 }
748 let content = match fs::read_to_string(entry.path()) {
749 Ok(c) => c,
750 Err(_) => continue,
751 };
752 for (i, line) in content.lines().enumerate() {
753 if re.is_match(line) {
754 out.push_str(&format!("{}:{}:{}\n", entry.path().display(), i + 1, line));
755 count += 1;
756 if count >= 500 {
757 out.push_str("(truncated at 500 matches)\n");
758 return Ok(out);
759 }
760 }
761 }
762 }
763 if out.is_empty() {
764 Ok("(no matches)".into())
765 } else {
766 Ok(out)
767 }
768 }
769}
770
771pub struct Fetch {
794 allow_hosts: Option<Vec<String>>,
795 max_bytes: usize,
796 agent: std::sync::OnceLock<ureq::Agent>,
800}
801
802const FETCH_DEFAULT_MAX: usize = 64 * 1024;
803
804impl Default for Fetch {
805 fn default() -> Self { Self::new() }
806}
807
808impl Fetch {
809 pub fn new() -> Self {
810 Self {
811 allow_hosts: None,
812 max_bytes: FETCH_DEFAULT_MAX,
813 agent: std::sync::OnceLock::new(),
814 }
815 }
816
817 pub fn with_allow_hosts(mut self, hosts: Vec<String>) -> Self {
822 self.allow_hosts = Some(hosts.into_iter().map(|h| h.to_lowercase()).collect());
823 self
824 }
825
826 pub fn with_max_bytes(mut self, n: usize) -> Self {
828 self.max_bytes = n;
829 self
830 }
831
832 fn agent(&self) -> &ureq::Agent {
837 self.agent.get_or_init(|| {
838 let resolver = match &self.allow_hosts {
839 Some(list) => crate::ssrf::SsrfResolver::with_allow_hosts(list.clone()),
840 None => crate::ssrf::SsrfResolver::new(),
841 };
842 let builder = ureq::AgentBuilder::new()
843 .resolver(resolver)
844 .redirects(0);
845 match native_tls::TlsConnector::new() {
846 Ok(connector) => builder.tls_connector(Arc::new(connector)).build(),
847 Err(_) => builder.build(),
848 }
849 })
850 }
851}
852
853fn fetch_url_shape_check(url: &str) -> Result<(), String> {
858 let parsed = url::Url::parse(url).map_err(|e| format!("url parse: {}", e))?;
859 let scheme = parsed.scheme();
860 if scheme != "http" && scheme != "https" {
861 return Err(format!("rejected scheme: {}", scheme));
862 }
863 if parsed.host_str().is_none() {
864 return Err("url has no host".to_string());
865 }
866 Ok(())
867}
868
869impl Tool for Fetch {
870 fn name(&self) -> &str { "fetch" }
871 fn description(&self) -> &str {
872 "HTTP GET a URL and return the response body (first 64KB by default). Rejects loopback / private / link-local / metadata hosts atomically via a custom DNS resolver."
873 }
874 fn schema(&self) -> Value {
875 json!({
876 "type": "object",
877 "properties": {
878 "url": { "type": "string" }
879 },
880 "required": ["url"]
881 })
882 }
883 fn call(&self, args: Value) -> Result<String, String> {
884 use std::io::Read;
885 let url = args["url"].as_str().ok_or("missing url")?;
886 fetch_url_shape_check(url)?;
887 let resp = self
888 .agent()
889 .get(url)
890 .call()
891 .map_err(|e| format!("fetch: {}", e))?;
892 let status = resp.status();
893 let mut body = String::new();
894 resp.into_reader()
895 .take(self.max_bytes as u64)
896 .read_to_string(&mut body)
897 .map_err(|e| format!("read: {}", e))?;
898 Ok(format!("HTTP {}\n{}", status, body))
899 }
900}
901
902#[cfg(test)]
907mod tests {
908 use super::*;
909 use std::fs;
910
911 fn tmpdir(tag: &str) -> PathBuf {
912 let d = std::env::temp_dir().join(format!(
913 "agnt-tools-{}-{}-{}",
914 tag,
915 std::process::id(),
916 std::time::SystemTime::now()
917 .duration_since(std::time::UNIX_EPOCH)
918 .map(|d| d.as_nanos())
919 .unwrap_or(0)
920 ));
921 fs::create_dir_all(&d).unwrap();
922 d
923 }
924
925 #[test]
928 fn sandbox_blocks_read_of_etc_shadow() {
929 let dir = tmpdir("sbx-read");
930 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
931 let tool = ReadFile::with_sandbox(sbx);
932 let res = tool.call(json!({"path":"/etc/shadow"}));
933 assert!(res.is_err(), "expected sandbox rejection");
934 }
935
936 #[test]
937 fn sandbox_blocks_write_outside_root() {
938 let dir = tmpdir("sbx-write");
939 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
940 let tool = WriteFile::with_sandbox(sbx);
941 let res = tool.call(json!({"path":"../escape.txt","content":"x"}));
942 assert!(res.is_err());
943 }
944
945 #[test]
946 fn sandbox_allows_read_under_root() {
947 let dir = tmpdir("sbx-ok");
948 fs::write(dir.join("hello.txt"), "world").unwrap();
949 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
950 let tool = ReadFile::with_sandbox(sbx);
951 let out = tool.call(json!({"path":"hello.txt"})).unwrap();
952 assert_eq!(out, "world");
953 }
954
955 #[test]
956 fn sandbox_blocks_listdir_of_root() {
957 let dir = tmpdir("sbx-ls");
958 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
959 let tool = ListDir::with_sandbox(sbx);
960 assert!(tool.call(json!({"path":"/"})).is_err());
961 }
962
963 #[test]
964 fn sandbox_blocks_glob_absolute() {
965 let dir = tmpdir("sbx-glob");
966 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
967 let tool = Glob::with_sandbox(sbx);
968 assert!(tool.call(json!({"pattern":"/etc/*"})).is_err());
969 }
970
971 #[test]
972 fn sandbox_blocks_glob_parent_traversal() {
973 let dir = tmpdir("sbx-glob2");
974 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
975 let tool = Glob::with_sandbox(sbx);
976 assert!(tool.call(json!({"pattern":"../*"})).is_err());
977 }
978
979 #[test]
980 fn sandbox_blocks_grep_root() {
981 let dir = tmpdir("sbx-grep");
982 let sbx = Arc::new(FilesystemRoot::new(&dir).unwrap());
983 let tool = Grep::with_sandbox(sbx);
984 assert!(tool.call(json!({"pattern":"root:","path":"/etc"})).is_err());
985 }
986
987 #[test]
990 fn fetch_rejects_aws_metadata_ip() {
991 let tool = Fetch::new();
992 let err = tool
993 .call(json!({"url":"http://169.254.169.254/latest/meta-data/"}))
994 .unwrap_err();
995 assert!(err.contains("metadata") || err.contains("link") || err.contains("169.254"));
996 }
997
998 #[test]
999 fn fetch_rejects_gcp_metadata_name() {
1000 let tool = Fetch::new();
1001 let err = tool
1002 .call(json!({"url":"http://metadata.google.internal/"}))
1003 .unwrap_err();
1004 assert!(err.contains("metadata"));
1005 }
1006
1007 #[test]
1008 fn fetch_rejects_loopback() {
1009 let tool = Fetch::new();
1010 let err = tool.call(json!({"url":"http://127.0.0.1:11434/"})).unwrap_err();
1011 assert!(err.contains("IP") || err.contains("loopback") || err.contains("127"));
1012 }
1013
1014 #[test]
1015 fn fetch_rejects_private_ipv4() {
1016 let tool = Fetch::new();
1017 let err = tool.call(json!({"url":"http://192.168.1.1/"})).unwrap_err();
1018 assert!(err.contains("IPv4") || err.contains("192.168") || err.contains("private"));
1019 }
1020
1021 #[test]
1022 fn fetch_rejects_file_scheme() {
1023 let tool = Fetch::new();
1024 let err = tool.call(json!({"url":"file:///etc/passwd"})).unwrap_err();
1025 assert!(err.contains("scheme"));
1026 }
1027
1028 #[test]
1029 fn fetch_rejects_localhost_name() {
1030 let tool = Fetch::new();
1031 let err = tool.call(json!({"url":"http://localhost:6379/"})).unwrap_err();
1032 assert!(err.contains("IP") || err.contains("loopback") || err.contains("127"));
1033 }
1034
1035 #[test]
1036 fn fetch_allowlist_blocks_non_matching_host_before_dns() {
1037 let tool = Fetch::new().with_allow_hosts(vec!["example.com".into()]);
1038 let err = tool.call(json!({"url":"http://metadata.google.internal/"})).unwrap_err();
1039 assert!(err.contains("metadata"));
1041 let tool2 = Fetch::new().with_allow_hosts(vec!["example.com".into()]);
1042 let err2 = tool2.call(json!({"url":"http://not-on-list.invalid/"})).unwrap_err();
1043 assert!(err2.contains("allowlist") || err2.contains("not-on-list"));
1044 }
1045
1046 #[test]
1047 fn fetch_uses_ssrf_resolver_atomically() {
1048 let tool = Fetch::new();
1057 let err = tool.call(json!({"url":"http://10.0.0.1/"})).unwrap_err();
1058 assert!(
1059 err.contains("IPv4") || err.contains("10.0.0.1") || err.contains("private"),
1060 "error should come from SsrfResolver: {}",
1061 err
1062 );
1063 }
1064
1065 #[test]
1066 fn fetch_ipv6_literal_loopback_rejected() {
1067 let tool = Fetch::new();
1068 let err = tool.call(json!({"url":"http://[::1]/"})).unwrap_err();
1069 assert!(err.contains("loopback") || err.contains("::1"), "got: {}", err);
1070 }
1071
1072 #[test]
1073 fn fetch_ipv6_literal_ula_rejected() {
1074 let tool = Fetch::new();
1075 let err = tool.call(json!({"url":"http://[fc00::1]/"})).unwrap_err();
1076 assert!(err.contains("IPv6") || err.contains("fc00"), "got: {}", err);
1077 }
1078
1079 #[test]
1082 fn edit_file_unique_match() {
1083 let dir = tmpdir("edit-unique");
1084 let p = dir.join("f.txt");
1085 fs::write(&p, "hello world").unwrap();
1086 let tool = EditFile::new();
1087 tool.call(json!({"path": p.to_str().unwrap(), "old":"world", "new":"agnt"})).unwrap();
1088 assert_eq!(fs::read_to_string(&p).unwrap(), "hello agnt");
1089 }
1090
1091 #[test]
1092 fn edit_file_concurrent_stress() {
1093 use std::sync::atomic::{AtomicUsize, Ordering};
1094 use std::thread;
1095
1096 let dir = tmpdir("edit-stress");
1097 let path = dir.join("race.txt");
1098 for round in 0..100 {
1102 fs::write(&path, format!("start-{}-MARK-end", round)).unwrap();
1103 let winners = Arc::new(AtomicUsize::new(0));
1104 thread::scope(|s| {
1105 for tid in 0..4 {
1106 let path = path.clone();
1107 let winners = winners.clone();
1108 s.spawn(move || {
1109 let tool = EditFile::new();
1110 let res = tool.call(json!({
1111 "path": path.to_str().unwrap(),
1112 "old": "MARK",
1113 "new": format!("T{}", tid),
1114 }));
1115 if res.is_ok() {
1116 winners.fetch_add(1, Ordering::SeqCst);
1117 }
1118 });
1119 }
1120 });
1121 assert_eq!(
1122 winners.load(Ordering::SeqCst),
1123 1,
1124 "expected exactly one winner per round, got {} on round {}",
1125 winners.load(Ordering::SeqCst),
1126 round
1127 );
1128 let final_content = fs::read_to_string(&path).unwrap();
1129 assert!(!final_content.contains("MARK"), "marker should be replaced");
1130 }
1131 }
1132
1133 #[cfg(feature = "shell")]
1136 #[test]
1137 fn shell_rejects_unknown_argv0() {
1138 let s = Shell::new_sandboxed(vec!["echo".into()], std::env::temp_dir());
1139 assert!(s.call(json!({"cmd":"rm -rf /"})).is_err());
1140 }
1141
1142 #[cfg(feature = "shell")]
1143 #[test]
1144 fn shell_rejects_command_substitution() {
1145 let s = Shell::new_sandboxed(vec!["echo".into()], std::env::temp_dir());
1146 let err = s.call(json!({"cmd":"echo $(whoami)"})).unwrap_err();
1148 assert!(err.contains("forbidden"));
1149 }
1150
1151 #[cfg(feature = "shell")]
1152 #[test]
1153 fn shell_rejects_pipe() {
1154 let s = Shell::new_sandboxed(vec!["echo".into()], std::env::temp_dir());
1155 let err = s.call(json!({"cmd":"echo hi | cat"})).unwrap_err();
1156 assert!(err.contains("forbidden") || err.contains("allowlist"));
1157 }
1158
1159 #[cfg(feature = "shell")]
1160 #[test]
1161 fn shell_allowlisted_echo_runs() {
1162 let s = Shell::new_sandboxed(vec!["echo".into()], std::env::temp_dir());
1163 let out = s.call(json!({"cmd":"echo hello"})).unwrap();
1164 assert!(out.contains("hello"));
1165 }
1166
1167 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
1170 #[test]
1171 fn bwrap_argv_contains_core_ro_binds_and_unshare() {
1172 let cfg = BwrapConfig { share_net: false };
1173 let cwd = PathBuf::from("/tmp/workdir-xyz");
1174 let argv = vec!["echo".to_string(), "hi".to_string()];
1175 let out = Shell::build_bwrap_argv(&cfg, &cwd, &argv);
1176 assert!(out.windows(3).any(|w| w == ["--ro-bind", "/usr", "/usr"]));
1178 assert!(out.windows(3).any(|w| w == ["--ro-bind", "/bin", "/bin"]));
1179 assert!(out.iter().any(|s| s == "--unshare-all"));
1181 assert!(out.iter().any(|s| s == "--die-with-parent"));
1182 assert!(out.iter().any(|s| s == "--tmpfs"));
1183 assert!(out.windows(3).any(|w| w[0] == "--bind" && w[1] == "/tmp/workdir-xyz"));
1185 let chdir_pos = out.iter().position(|s| s == "--chdir").expect("chdir");
1186 assert_eq!(out[chdir_pos + 1], "/tmp/workdir-xyz");
1187 let sep = out.iter().rposition(|s| s == "--").expect("-- sep");
1189 assert_eq!(&out[sep + 1..], &["echo".to_string(), "hi".to_string()][..]);
1190 }
1191
1192 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
1193 #[test]
1194 fn bwrap_share_net_flag_toggles() {
1195 let cwd = PathBuf::from("/tmp/nw");
1196 let argv = vec!["echo".to_string()];
1197 let off = Shell::build_bwrap_argv(&BwrapConfig { share_net: false }, &cwd, &argv);
1198 assert!(!off.iter().any(|s| s == "--share-net"));
1199 let on = Shell::build_bwrap_argv(&BwrapConfig { share_net: true }, &cwd, &argv);
1200 assert!(on.iter().any(|s| s == "--share-net"));
1201 }
1202
1203 #[cfg(all(feature = "bwrap-shell", target_os = "linux"))]
1204 #[test]
1205 #[ignore = "requires bwrap installed locally"]
1206 fn bwrap_echo_runs_under_sandbox() {
1207 let s = Shell::new_bwrap(vec!["echo".into()], std::env::temp_dir(), false)
1208 .expect("bwrap must be installed to run this test");
1209 let out = s.call(json!({"cmd":"echo sandboxed"})).unwrap();
1210 assert!(out.contains("sandboxed"));
1211 }
1212}