1use super::{Capabilities, Tool, ToolCtx, ToolOutput};
8use crate::sandbox::Sandbox;
9use anyhow::Result;
10use async_trait::async_trait;
11use futures::StreamExt;
12use serde_json::{json, Value};
13use std::sync::Arc;
14
15pub fn all(sandbox: Arc<Sandbox>) -> Vec<Arc<dyn Tool>> {
16 vec![
17 Arc::new(FsRead),
18 Arc::new(FsWrite),
19 Arc::new(FsEdit),
20 Arc::new(FsList),
21 Arc::new(Shell::new(sandbox)),
22 Arc::new(HttpFetch),
23 Arc::new(super::todo::TodoTool::new()),
24 ]
25}
26
27const MAX_OUTPUT_BYTES: usize = 200_000;
30
31fn truncate(mut s: String, what: &str) -> String {
32 if s.len() > MAX_OUTPUT_BYTES {
33 let mut cut = MAX_OUTPUT_BYTES;
34 while !s.is_char_boundary(cut) {
35 cut -= 1;
36 }
37 let total = s.len();
38 s.truncate(cut);
39 s.push_str(&format!(
40 "\n\n[truncated: {what} was {total} bytes, showing first {cut}]"
41 ));
42 }
43 s
44}
45
46fn arg_str<'a>(input: &'a Value, key: &str) -> Result<&'a str> {
47 input
48 .get(key)
49 .and_then(Value::as_str)
50 .ok_or_else(|| anyhow::anyhow!("missing required string argument `{key}`"))
51}
52
53pub struct FsRead;
54
55#[async_trait]
56impl Tool for FsRead {
57 fn name(&self) -> &str {
58 "fs_read"
59 }
60
61 fn description(&self) -> &str {
62 "Read a UTF-8 text file from the workspace. Use `offset` and `limit` (1-indexed lines) \
63 to read part of a large file."
64 }
65
66 fn input_schema(&self) -> Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "path": {"type": "string", "description": "Path relative to the workspace root, or absolute inside it."},
71 "offset": {"type": "integer", "description": "First line to return, 1-indexed."},
72 "limit": {"type": "integer", "description": "Maximum number of lines to return."}
73 },
74 "required": ["path"]
75 })
76 }
77
78 fn read_only(&self) -> bool {
79 true
80 }
81
82 fn capabilities(&self) -> Capabilities {
83 Capabilities::default().private()
85 }
86
87 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
88 let path = ctx.resolve(arg_str(&input, "path")?)?;
89 let text = match tokio::fs::read_to_string(&path).await {
90 Ok(t) => t,
91 Err(e) => {
92 return Ok(ToolOutput::err(format!(
93 "cannot read {}: {e}",
94 path.display()
95 )))
96 }
97 };
98
99 let offset = input
100 .get("offset")
101 .and_then(Value::as_u64)
102 .unwrap_or(1)
103 .max(1) as usize;
104 let limit = input
105 .get("limit")
106 .and_then(Value::as_u64)
107 .map(|l| l as usize);
108 if offset == 1 && limit.is_none() {
109 return Ok(ToolOutput::ok(truncate(text, "file")));
110 }
111
112 let selected: Vec<&str> = text
113 .lines()
114 .skip(offset - 1)
115 .take(limit.unwrap_or(usize::MAX))
116 .collect();
117 Ok(ToolOutput::ok(truncate(selected.join("\n"), "selection")))
118 }
119}
120
121pub struct FsWrite;
122
123#[async_trait]
124impl Tool for FsWrite {
125 fn name(&self) -> &str {
126 "fs_write"
127 }
128
129 fn description(&self) -> &str {
130 "Create a file or replace its entire contents. To change part of an existing file, \
131 prefer fs_edit."
132 }
133
134 fn input_schema(&self) -> Value {
135 json!({
136 "type": "object",
137 "properties": {
138 "path": {"type": "string"},
139 "content": {"type": "string"}
140 },
141 "required": ["path", "content"]
142 })
143 }
144
145 fn capabilities(&self) -> Capabilities {
146 Capabilities::default().destructive()
147 }
148
149 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
150 let path = ctx.resolve(arg_str(&input, "path")?)?;
151 let content = arg_str(&input, "content")?;
152 if let Some(parent) = path.parent() {
153 tokio::fs::create_dir_all(parent).await?;
154 }
155 match tokio::fs::write(&path, content).await {
156 Ok(()) => Ok(ToolOutput::ok(format!(
157 "wrote {} bytes to {}",
158 content.len(),
159 path.display()
160 ))),
161 Err(e) => Ok(ToolOutput::err(format!(
162 "cannot write {}: {e}",
163 path.display()
164 ))),
165 }
166 }
167}
168
169pub struct FsEdit;
170
171#[async_trait]
172impl Tool for FsEdit {
173 fn name(&self) -> &str {
174 "fs_edit"
175 }
176
177 fn description(&self) -> &str {
178 "Replace one exact occurrence of `old` with `new` in a file. Fails if `old` appears \
179 zero times or more than once, so include enough surrounding context to be unique."
180 }
181
182 fn input_schema(&self) -> Value {
183 json!({
184 "type": "object",
185 "properties": {
186 "path": {"type": "string"},
187 "old": {"type": "string", "description": "Exact text to replace, including indentation."},
188 "new": {"type": "string"}
189 },
190 "required": ["path", "old", "new"]
191 })
192 }
193
194 fn capabilities(&self) -> Capabilities {
195 Capabilities::default().destructive()
196 }
197
198 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
199 let path = ctx.resolve(arg_str(&input, "path")?)?;
200 let old = arg_str(&input, "old")?;
201 let new = arg_str(&input, "new")?;
202
203 let text = match tokio::fs::read_to_string(&path).await {
204 Ok(t) => t,
205 Err(e) => {
206 return Ok(ToolOutput::err(format!(
207 "cannot read {}: {e}",
208 path.display()
209 )))
210 }
211 };
212
213 match text.matches(old).count() {
215 0 => return Ok(ToolOutput::err("`old` does not appear in the file")),
216 1 => {}
217 n => {
218 return Ok(ToolOutput::err(format!(
219 "`old` appears {n} times; include more surrounding context to make it unique"
220 )))
221 }
222 }
223
224 tokio::fs::write(&path, text.replacen(old, new, 1)).await?;
225 Ok(ToolOutput::ok(format!("edited {}", path.display())))
226 }
227}
228
229pub struct FsList;
230
231#[async_trait]
232impl Tool for FsList {
233 fn name(&self) -> &str {
234 "fs_list"
235 }
236
237 fn description(&self) -> &str {
238 "List the entries of a directory. Directories are suffixed with `/`."
239 }
240
241 fn input_schema(&self) -> Value {
242 json!({
243 "type": "object",
244 "properties": {
245 "path": {"type": "string", "description": "Defaults to the workspace root."}
246 }
247 })
248 }
249
250 fn read_only(&self) -> bool {
251 true
252 }
253
254 fn capabilities(&self) -> Capabilities {
255 Capabilities::default().private()
256 }
257
258 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
259 let raw = input.get("path").and_then(Value::as_str).unwrap_or(".");
260 let path = ctx.resolve(raw)?;
261 let mut entries = match tokio::fs::read_dir(&path).await {
262 Ok(e) => e,
263 Err(e) => {
264 return Ok(ToolOutput::err(format!(
265 "cannot list {}: {e}",
266 path.display()
267 )))
268 }
269 };
270
271 let mut out = Vec::new();
272 while let Some(entry) = entries.next_entry().await? {
273 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
274 let name = entry.file_name().to_string_lossy().to_string();
275 out.push(if is_dir { format!("{name}/") } else { name });
276 }
277 out.sort();
278 Ok(ToolOutput::ok(if out.is_empty() {
279 "(empty directory)".to_string()
280 } else {
281 out.join("\n")
282 }))
283 }
284}
285
286pub struct Shell {
293 sandbox: Arc<Sandbox>,
294}
295
296impl Shell {
297 pub fn new(sandbox: Arc<Sandbox>) -> Self {
298 Shell { sandbox }
299 }
300}
301
302#[async_trait]
303impl Tool for Shell {
304 fn name(&self) -> &str {
305 "shell"
306 }
307
308 fn description(&self) -> &str {
309 "Run a shell command in the workspace and return its combined stdout and stderr. \
310 The command runs to completion; long-running or interactive commands will time out."
311 }
312
313 fn input_schema(&self) -> Value {
314 json!({
315 "type": "object",
316 "properties": {
317 "command": {"type": "string"},
318 "cwd": {"type": "string", "description": "Working directory, defaults to the workspace root."}
319 },
320 "required": ["command"]
321 })
322 }
323
324 fn capabilities(&self) -> Capabilities {
325 Capabilities {
344 private_data: true,
345 untrusted_input: false,
346 external_send: self.sandbox.can_reach_network(),
347 destructive: true,
348 }
349 }
350
351 fn denial_remedy(&self) -> Option<String> {
357 if !self.sandbox.is_enabled() {
358 Some(
359 "The durable fix is confinement, not looser policy: add `[sandbox]` \
360 with `kind = \"bwrap\"` or `\"docker\"` and `network = false` to \
361 ~/.mecha/config.toml. A confined shell cannot send, so local \
362 commands stop tripping this interlock entirely."
363 .into(),
364 )
365 } else if self.sandbox.backend() == crate::sandbox::Backend::Landlock {
366 Some(
370 "The landlock sandbox confines files but cannot close the network \
371 (UDP is not restrictable), so shell still counts as a send route \
372 whatever `network` is set to. The interlock relaxation needs \
373 `kind = \"bwrap\"` or `\"docker\"` with `network = false`."
374 .into(),
375 )
376 } else if self.sandbox.can_reach_network() {
377 Some(
378 "The sandbox is on but shares the host's network (`network = true`), \
379 which is why shell still counts as a send route. Setting \
380 `network = false` in `[sandbox]` exempts local commands from this \
381 interlock."
382 .into(),
383 )
384 } else {
385 None
386 }
387 }
388
389 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
390 let command = arg_str(&input, "command")?;
391 let cwd = match input.get("cwd").and_then(Value::as_str) {
392 Some(c) => ctx.resolve(c)?,
393 None => ctx.workspace.clone(),
394 };
395
396 let mut command = match self.sandbox.command(command, &ctx.workspace, &cwd) {
400 Ok(c) => c,
401 Err(e) => {
402 return Ok(ToolOutput::err(format!(
403 "refusing to run: the {} sandbox could not be set up ({e:#}). \
404 Nothing was executed.",
405 self.sandbox.backend().as_str()
406 )))
407 }
408 };
409
410 command
416 .stdin(std::process::Stdio::null())
417 .stdout(std::process::Stdio::piped())
418 .stderr(std::process::Stdio::piped())
419 .kill_on_drop(true);
420 let mut child = match command.spawn() {
421 Ok(c) => c,
422 Err(e) => return Ok(ToolOutput::err(format!("cannot run command: {e}"))),
423 };
424 let out_pipe = child.stdout.take();
425 let err_pipe = child.stderr.take();
426
427 let fut = async {
428 tokio::join!(
429 drain_capped(out_pipe, MAX_OUTPUT_BYTES),
430 drain_capped(err_pipe, MAX_OUTPUT_BYTES),
431 child.wait(),
432 )
433 };
434 let ((stdout, out_dropped), (stderr, err_dropped), status) =
435 match tokio::time::timeout(ctx.shell_timeout, fut).await {
436 Err(_) => {
437 return Ok(ToolOutput::err(format!(
438 "command timed out after {}s",
439 ctx.shell_timeout.as_secs()
440 )))
441 }
442 Ok(v) => v,
443 };
444 let status = match status {
445 Ok(s) => s,
446 Err(e) => return Ok(ToolOutput::err(format!("cannot run command: {e}"))),
447 };
448
449 let mut body = String::new();
450 body.push_str(&String::from_utf8_lossy(&stdout));
451 if !stderr.is_empty() {
452 if !body.is_empty() && !body.ends_with('\n') {
453 body.push('\n');
454 }
455 body.push_str(&String::from_utf8_lossy(&stderr));
456 }
457 let mut body = truncate(body, "output");
460 if out_dropped || err_dropped {
461 if !body.is_empty() && !body.ends_with('\n') {
462 body.push('\n');
463 }
464 body.push_str(&format!(
465 "[output exceeded {MAX_OUTPUT_BYTES} bytes; the rest was discarded as it streamed. \
466 Redirect to a file and read it in pieces if more is needed.]"
467 ));
468 }
469 if body.trim().is_empty() {
470 body.push_str("(no output)");
471 }
472
473 let code = status.code().unwrap_or(-1);
474 if code != 0 {
475 body = format!("exit status {code}\n{body}");
476 }
477 Ok(ToolOutput {
478 content: body,
479 is_error: code != 0,
480 external: false,
481 })
482 }
483}
484
485async fn drain_capped(
490 pipe: Option<impl tokio::io::AsyncRead + Unpin>,
491 cap: usize,
492) -> (Vec<u8>, bool) {
493 use tokio::io::AsyncReadExt;
494 let Some(mut pipe) = pipe else {
495 return (Vec::new(), false);
496 };
497 let mut kept = Vec::new();
498 let mut dropped = false;
499 let mut buf = [0u8; 8192];
500 loop {
501 match pipe.read(&mut buf).await {
502 Ok(0) | Err(_) => break,
503 Ok(n) => {
504 let take = n.min(cap.saturating_sub(kept.len()));
505 kept.extend_from_slice(&buf[..take]);
506 dropped |= take < n;
507 }
508 }
509 }
510 (kept, dropped)
511}
512
513pub struct HttpFetch;
514
515#[async_trait]
516impl Tool for HttpFetch {
517 fn name(&self) -> &str {
518 "http_fetch"
519 }
520
521 fn description(&self) -> &str {
522 "Fetch a URL over HTTP(S) and return the response body as text."
523 }
524
525 fn input_schema(&self) -> Value {
526 json!({
527 "type": "object",
528 "properties": {
529 "url": {"type": "string"}
530 },
531 "required": ["url"]
532 })
533 }
534
535 fn read_only(&self) -> bool {
536 true
539 }
540
541 fn capabilities(&self) -> Capabilities {
542 Capabilities::default().untrusted().sends()
543 }
544
545 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
546 let url = arg_str(&input, "url")?;
547 let vetted = match check_url(url, ctx).await {
548 Ok(v) => v,
549 Err(e) => return Ok(ToolOutput::err(e.to_string())),
550 };
551
552 let mut builder = reqwest::Client::builder()
553 .timeout(std::time::Duration::from_secs(30))
554 .redirect(reqwest::redirect::Policy::none());
557 if let Some((host, addrs)) = &vetted {
562 builder = builder.resolve_to_addrs(host, addrs);
563 }
564 let client = builder.build()?;
565 let resp = match client.get(url).send().await {
566 Ok(r) => r,
567 Err(e) => return Ok(ToolOutput::err(format!("request failed: {e}"))),
568 };
569
570 if resp.status().is_redirection() {
571 let target = resp
572 .headers()
573 .get("location")
574 .and_then(|v| v.to_str().ok())
575 .unwrap_or("(no location header)");
576 return Ok(ToolOutput::err(format!(
577 "{} redirect to {target} — not followed. Call http_fetch again with that URL if you want it.",
578 resp.status()
579 )));
580 }
581
582 let status = resp.status();
583 let mut raw: Vec<u8> = Vec::new();
587 let mut body_stream = resp.bytes_stream();
588 while let Some(chunk) = body_stream.next().await {
589 match chunk {
590 Ok(c) => raw.extend_from_slice(&c),
591 Err(e) => {
592 return Ok(ToolOutput::err(format!(
593 "reading the response body failed: {e}"
594 )))
595 }
596 }
597 if raw.len() > MAX_OUTPUT_BYTES {
598 break;
599 }
600 }
601 let body = String::from_utf8_lossy(&raw);
602 Ok(ToolOutput {
605 content: truncate(format!("HTTP {status}\n\n{body}"), "body"),
606 is_error: !status.is_success(),
607 external: true,
608 })
609 }
610}
611
612async fn check_url(
621 url: &str,
622 ctx: &ToolCtx,
623) -> Result<Option<(String, Vec<std::net::SocketAddr>)>> {
624 let parsed = reqwest::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid url: {e}"))?;
625 match parsed.scheme() {
626 "http" | "https" => {}
627 other => anyhow::bail!("scheme {other:?} is not allowed (use http or https)"),
628 }
629 let host = parsed
630 .host_str()
631 .ok_or_else(|| anyhow::anyhow!("url has no host"))?
632 .to_ascii_lowercase();
633
634 let policy = &ctx.security;
635 let matches = |pattern: &str| {
636 let pattern = pattern.trim_start_matches('.').to_ascii_lowercase();
637 host == pattern || host.ends_with(&format!(".{pattern}"))
638 };
639
640 if policy.blocked_domains.iter().any(|d| matches(d)) {
641 anyhow::bail!("{host} is on the blocked-domain list");
642 }
643 if !policy.allowed_domains.is_empty() && !policy.allowed_domains.iter().any(|d| matches(d)) {
644 anyhow::bail!("{host} is not on the allowed-domain list");
645 }
646
647 if policy.block_private_ips {
648 let port = parsed.port_or_known_default().unwrap_or(80);
651 let addrs = tokio::net::lookup_host((host.as_str(), port))
652 .await
653 .map_err(|e| anyhow::anyhow!("cannot resolve {host}: {e}"))?;
654
655 let mut vetted = Vec::new();
656 for addr in addrs {
657 if is_internal(&addr.ip()) {
658 anyhow::bail!(
659 "{host} resolves to the internal address {} — refused",
660 addr.ip()
661 );
662 }
663 vetted.push(addr);
664 }
665 if vetted.is_empty() {
666 anyhow::bail!("{host} did not resolve to any address");
667 }
668 return Ok(Some((host, vetted)));
669 }
670
671 Ok(None)
672}
673
674fn is_internal(ip: &std::net::IpAddr) -> bool {
676 use std::net::IpAddr;
677 match ip {
678 IpAddr::V4(v4) => {
679 v4.is_loopback()
680 || v4.is_private()
681 || v4.is_link_local()
683 || v4.is_broadcast()
684 || v4.is_documentation()
685 || v4.is_unspecified()
686 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
688 || v4.octets()[0] == 0
690 }
691 IpAddr::V6(v6) => {
692 v6.is_loopback()
693 || v6.is_unspecified()
694 || (v6.segments()[0] & 0xfe00) == 0xfc00
696 || (v6.segments()[0] & 0xffc0) == 0xfe80
697 || v6.to_ipv4_mapped().is_some_and(|v4| is_internal(&IpAddr::V4(v4)))
699 }
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::sandbox::{Backend, SandboxConfig};
707 use crate::tool::ToolCtx;
708
709 fn shell_with(kind: Backend, network: bool) -> Shell {
710 Shell::new(Arc::new(Sandbox::new(SandboxConfig {
711 kind,
712 network,
713 ..SandboxConfig::default()
714 })))
715 }
716
717 #[test]
718 fn confining_the_shell_closes_the_send_route_and_nothing_else() {
719 let loose = shell_with(Backend::None, false).capabilities();
720 assert!(loose.private_data && loose.external_send && loose.destructive);
721
722 let confined = shell_with(Backend::Bwrap, false).capabilities();
725 assert!(!confined.external_send, "no network means no way out");
726 assert!(confined.destructive, "it can still destroy the workspace");
727
728 assert!(
730 shell_with(Backend::Bwrap, true)
731 .capabilities()
732 .external_send
733 );
734 }
735
736 #[test]
737 fn the_remedy_names_the_condition_that_set_the_bit() {
738 let r = shell_with(Backend::None, false).denial_remedy().unwrap();
741 assert!(r.contains("[sandbox]") && r.contains("network = false"));
742
743 let r = shell_with(Backend::Bwrap, true).denial_remedy().unwrap();
747 assert!(r.contains("network = false"));
748 assert!(
749 !r.contains("kind ="),
750 "the sandbox is on; do not advise enabling it"
751 );
752
753 assert!(shell_with(Backend::Docker, false).denial_remedy().is_none());
757 }
758
759 #[test]
760 fn a_confined_shell_is_still_private_because_it_still_reads_your_files() {
761 for (kind, network) in [
767 (Backend::None, false),
768 (Backend::Bwrap, false),
769 (Backend::Docker, false),
770 ] {
771 assert!(
772 shell_with(kind, network).capabilities().private_data,
773 "{kind:?} shell reads the workspace, exactly as fs_read does"
774 );
775 }
776 assert!(
777 FsRead.capabilities().private_data,
778 "the rule this is matching"
779 );
780 }
781
782 fn ctx(dir: &std::path::Path) -> ToolCtx {
783 ToolCtx {
784 workspace: dir.to_path_buf(),
785 shell_timeout: std::time::Duration::from_secs(5),
786 ..Default::default()
787 }
788 }
789
790 #[tokio::test]
791 async fn escaping_the_workspace_is_refused() {
792 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
793 std::fs::create_dir_all(&dir).unwrap();
794 let ctx = ctx(&dir);
795
796 assert!(ctx.resolve("../../etc/passwd").is_err());
797 assert!(ctx.resolve("/etc/passwd").is_err());
798 assert!(ctx.resolve("notes.md").is_ok());
799
800 std::fs::remove_dir_all(&dir).ok();
801 }
802
803 #[tokio::test]
804 async fn a_command_that_floods_stdout_is_capped_not_buffered() {
805 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
806 std::fs::create_dir_all(&dir).unwrap();
807 let shell = shell_with(Backend::None, false);
808
809 let out = shell
813 .call(
814 json!({"command": "yes flood | head -c 1000000"}),
815 &ctx(&dir),
816 )
817 .await
818 .unwrap();
819
820 assert!(!out.is_error, "{}", out.content);
821 assert!(
822 out.content.len() <= MAX_OUTPUT_BYTES + 300,
823 "kept {} bytes",
824 out.content.len()
825 );
826 assert!(out.content.contains("discarded"), "the cut must be named");
827
828 std::fs::remove_dir_all(&dir).ok();
829 }
830
831 #[tokio::test]
832 async fn an_oversized_response_body_is_cut_at_the_cap_not_buffered_whole() {
833 use tokio::io::AsyncWriteExt;
834
835 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
838 let addr = listener.local_addr().unwrap();
839 tokio::spawn(async move {
840 use tokio::io::AsyncReadExt;
841 let (mut sock, _) = listener.accept().await.unwrap();
842 let mut req = Vec::new();
845 let mut tmp = [0u8; 4096];
846 while !req.windows(4).any(|w| w == b"\r\n\r\n") {
847 match sock.read(&mut tmp).await {
848 Ok(0) | Err(_) => break,
849 Ok(n) => req.extend_from_slice(&tmp[..n]),
850 }
851 }
852 let body = vec![b'a'; 1_000_000];
853 let head = format!(
854 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
855 body.len()
856 );
857 let _ = sock.write_all(head.as_bytes()).await;
858 let _ = sock.write_all(&body).await;
859 let _ = sock.shutdown().await;
860 });
861
862 let ctx = ToolCtx {
864 security: crate::config::SecurityConfig {
865 block_private_ips: false,
866 ..Default::default()
867 },
868 ..ToolCtx::default()
869 };
870 let out = HttpFetch
871 .call(json!({"url": format!("http://{addr}/big")}), &ctx)
872 .await
873 .unwrap();
874
875 assert!(out.external, "not an HTTP response: {}", out.content);
876 assert!(
877 out.content.len() <= MAX_OUTPUT_BYTES + 300,
878 "kept {} bytes",
879 out.content.len()
880 );
881 assert!(out.content.contains("[truncated"), "the cut must be named");
882 }
883
884 #[tokio::test]
885 async fn internal_addresses_are_refused_and_public_ones_come_back_pinned() {
886 let ctx = ToolCtx::default();
887
888 for url in [
890 "http://localhost/x",
891 "http://127.0.0.1/x",
892 "http://169.254.169.254/meta",
893 ] {
894 let err = check_url(url, &ctx).await.unwrap_err().to_string();
895 assert!(err.contains("internal"), "{url}: {err}");
896 }
897
898 let vetted = check_url("http://93.184.216.34/x", &ctx).await.unwrap();
902 let (host, addrs) = vetted.expect("the private-IP guard is on, so there is a pin");
903 assert_eq!(host, "93.184.216.34");
904 assert_eq!(addrs, vec!["93.184.216.34:80".parse().unwrap()]);
905
906 let open = ToolCtx {
908 security: crate::config::SecurityConfig {
909 block_private_ips: false,
910 ..Default::default()
911 },
912 ..ToolCtx::default()
913 };
914 assert!(check_url("http://127.0.0.1/x", &open)
915 .await
916 .unwrap()
917 .is_none());
918 }
919
920 #[tokio::test]
921 async fn edit_refuses_ambiguous_matches() {
922 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
923 std::fs::create_dir_all(&dir).unwrap();
924 std::fs::write(dir.join("f.txt"), "a\na\n").unwrap();
925
926 let out = FsEdit
927 .call(json!({"path": "f.txt", "old": "a", "new": "b"}), &ctx(&dir))
928 .await
929 .unwrap();
930 assert!(out.is_error);
931 assert!(out.content.contains("appears 2 times"));
932
933 std::fs::remove_dir_all(&dir).ok();
934 }
935}