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 if e.kind() == std::io::ErrorKind::InvalidData {
108 let size = tokio::fs::metadata(&path)
109 .await
110 .map(|m| m.len())
111 .unwrap_or(0);
112 let what = crate::message::image_media_type(&path)
113 .map(|m| format!("an image ({m})"))
114 .unwrap_or_else(|| "a binary file".to_string());
115 let advice = if crate::message::image_media_type(&path).is_some() {
116 " Nothing you can call will turn it into text. If it was attached to \
117 this conversation you can already see it; if it was not, say so rather \
118 than describing it."
119 } else {
120 ""
121 };
122 return Ok(ToolOutput::err(format!(
123 "{} is {what}, {size} bytes — not a UTF-8 text file, so there is nothing \
124 here to read.{advice}",
125 path.display(),
126 )));
127 }
128 return Ok(ToolOutput::err(format!(
129 "cannot read {}: {e}",
130 path.display()
131 )));
132 }
133 };
134
135 let offset = input
136 .get("offset")
137 .and_then(Value::as_u64)
138 .unwrap_or(1)
139 .max(1) as usize;
140 let limit = input
141 .get("limit")
142 .and_then(Value::as_u64)
143 .map(|l| l as usize);
144 if offset == 1 && limit.is_none() {
145 return Ok(ToolOutput::ok(truncate(text, "file")));
146 }
147
148 let selected: Vec<&str> = text
149 .lines()
150 .skip(offset - 1)
151 .take(limit.unwrap_or(usize::MAX))
152 .collect();
153 Ok(ToolOutput::ok(truncate(selected.join("\n"), "selection")))
154 }
155}
156
157pub struct FsWrite;
158
159#[async_trait]
160impl Tool for FsWrite {
161 fn name(&self) -> &str {
162 "fs_write"
163 }
164
165 fn description(&self) -> &str {
166 "Create a file or replace its entire contents. To change part of an existing file, \
167 prefer fs_edit."
168 }
169
170 fn input_schema(&self) -> Value {
171 json!({
172 "type": "object",
173 "properties": {
174 "path": {"type": "string"},
175 "content": {"type": "string"}
176 },
177 "required": ["path", "content"]
178 })
179 }
180
181 fn capabilities(&self) -> Capabilities {
182 Capabilities::default().destructive()
183 }
184
185 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
186 let path = ctx.resolve(arg_str(&input, "path")?)?;
187 let content = arg_str(&input, "content")?;
188 if let Some(parent) = path.parent() {
189 tokio::fs::create_dir_all(parent).await?;
190 }
191 match tokio::fs::write(&path, content).await {
192 Ok(()) => Ok(ToolOutput::ok(format!(
193 "wrote {} bytes to {}",
194 content.len(),
195 path.display()
196 ))),
197 Err(e) => Ok(ToolOutput::err(format!(
198 "cannot write {}: {e}",
199 path.display()
200 ))),
201 }
202 }
203}
204
205pub struct FsEdit;
206
207#[async_trait]
208impl Tool for FsEdit {
209 fn name(&self) -> &str {
210 "fs_edit"
211 }
212
213 fn description(&self) -> &str {
214 "Replace one exact occurrence of `old` with `new` in a file. Fails if `old` appears \
215 zero times or more than once, so include enough surrounding context to be unique."
216 }
217
218 fn input_schema(&self) -> Value {
219 json!({
220 "type": "object",
221 "properties": {
222 "path": {"type": "string"},
223 "old": {"type": "string", "description": "Exact text to replace, including indentation."},
224 "new": {"type": "string"}
225 },
226 "required": ["path", "old", "new"]
227 })
228 }
229
230 fn capabilities(&self) -> Capabilities {
231 Capabilities::default().destructive()
232 }
233
234 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
235 let path = ctx.resolve(arg_str(&input, "path")?)?;
236 let old = arg_str(&input, "old")?;
237 let new = arg_str(&input, "new")?;
238
239 let text = match tokio::fs::read_to_string(&path).await {
240 Ok(t) => t,
241 Err(e) => {
242 return Ok(ToolOutput::err(format!(
243 "cannot read {}: {e}",
244 path.display()
245 )))
246 }
247 };
248
249 match text.matches(old).count() {
251 0 => return Ok(ToolOutput::err("`old` does not appear in the file")),
252 1 => {}
253 n => {
254 return Ok(ToolOutput::err(format!(
255 "`old` appears {n} times; include more surrounding context to make it unique"
256 )))
257 }
258 }
259
260 tokio::fs::write(&path, text.replacen(old, new, 1)).await?;
261 Ok(ToolOutput::ok(format!("edited {}", path.display())))
262 }
263}
264
265pub struct FsList;
266
267#[async_trait]
268impl Tool for FsList {
269 fn name(&self) -> &str {
270 "fs_list"
271 }
272
273 fn description(&self) -> &str {
274 "List the entries of a directory. Directories are suffixed with `/`."
275 }
276
277 fn input_schema(&self) -> Value {
278 json!({
279 "type": "object",
280 "properties": {
281 "path": {"type": "string", "description": "Defaults to the workspace root."}
282 }
283 })
284 }
285
286 fn read_only(&self) -> bool {
287 true
288 }
289
290 fn capabilities(&self) -> Capabilities {
291 Capabilities::default().private()
292 }
293
294 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
295 let raw = input.get("path").and_then(Value::as_str).unwrap_or(".");
296 let path = ctx.resolve(raw)?;
297 let mut entries = match tokio::fs::read_dir(&path).await {
298 Ok(e) => e,
299 Err(e) => {
300 return Ok(ToolOutput::err(format!(
301 "cannot list {}: {e}",
302 path.display()
303 )))
304 }
305 };
306
307 let mut out = Vec::new();
308 while let Some(entry) = entries.next_entry().await? {
309 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
310 let name = entry.file_name().to_string_lossy().to_string();
311 out.push(if is_dir { format!("{name}/") } else { name });
312 }
313 out.sort();
314 Ok(ToolOutput::ok(if out.is_empty() {
315 "(empty directory)".to_string()
316 } else {
317 out.join("\n")
318 }))
319 }
320}
321
322pub struct Shell {
329 sandbox: Arc<Sandbox>,
330}
331
332impl Shell {
333 pub fn new(sandbox: Arc<Sandbox>) -> Self {
334 Shell { sandbox }
335 }
336}
337
338#[async_trait]
339impl Tool for Shell {
340 fn name(&self) -> &str {
341 "shell"
342 }
343
344 fn description(&self) -> &str {
345 "Run a shell command in the workspace and return its combined stdout and stderr. \
346 The command runs to completion; long-running or interactive commands will time out."
347 }
348
349 fn input_schema(&self) -> Value {
350 json!({
351 "type": "object",
352 "properties": {
353 "command": {"type": "string"},
354 "cwd": {"type": "string", "description": "Working directory, defaults to the workspace root."}
355 },
356 "required": ["command"]
357 })
358 }
359
360 fn capabilities(&self) -> Capabilities {
361 Capabilities {
380 private_data: true,
381 untrusted_input: false,
382 external_send: self.sandbox.can_reach_network(),
383 destructive: true,
384 }
385 }
386
387 fn denial_remedy(&self) -> Option<String> {
393 if !self.sandbox.is_enabled() {
394 Some(
395 "The durable fix is confinement, not looser policy: add `[sandbox]` \
396 with `kind = \"bwrap\"` or `\"docker\"` and `network = false` to \
397 ~/.mecha/config.toml. A confined shell cannot send, so local \
398 commands stop tripping this interlock entirely."
399 .into(),
400 )
401 } else if self.sandbox.backend() == crate::sandbox::Backend::Landlock {
402 Some(
406 "The landlock sandbox confines files but cannot close the network \
407 (UDP is not restrictable), so shell still counts as a send route \
408 whatever `network` is set to. The interlock relaxation needs \
409 `kind = \"bwrap\"` or `\"docker\"` with `network = false`."
410 .into(),
411 )
412 } else if self.sandbox.can_reach_network() {
413 Some(
414 "The sandbox is on but shares the host's network (`network = true`), \
415 which is why shell still counts as a send route. Setting \
416 `network = false` in `[sandbox]` exempts local commands from this \
417 interlock."
418 .into(),
419 )
420 } else {
421 None
422 }
423 }
424
425 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
426 let command = arg_str(&input, "command")?;
427 let cwd = match input.get("cwd").and_then(Value::as_str) {
428 Some(c) => ctx.resolve(c)?,
429 None => ctx.workspace.clone(),
430 };
431
432 let mut command = match self.sandbox.command(command, &ctx.workspace, &cwd) {
436 Ok(c) => c,
437 Err(e) => {
438 return Ok(ToolOutput::err(format!(
439 "refusing to run: the {} sandbox could not be set up ({e:#}). \
440 Nothing was executed.",
441 self.sandbox.backend().as_str()
442 )))
443 }
444 };
445
446 command
452 .stdin(std::process::Stdio::null())
453 .stdout(std::process::Stdio::piped())
454 .stderr(std::process::Stdio::piped())
455 .kill_on_drop(true);
456 let mut child = match command.spawn() {
457 Ok(c) => c,
458 Err(e) => return Ok(ToolOutput::err(format!("cannot run command: {e}"))),
459 };
460 let out_pipe = child.stdout.take();
461 let err_pipe = child.stderr.take();
462
463 let fut = async {
464 tokio::join!(
465 drain_capped(out_pipe, MAX_OUTPUT_BYTES),
466 drain_capped(err_pipe, MAX_OUTPUT_BYTES),
467 child.wait(),
468 )
469 };
470 let ((stdout, out_dropped), (stderr, err_dropped), status) =
471 match tokio::time::timeout(ctx.shell_timeout, fut).await {
472 Err(_) => {
473 return Ok(ToolOutput::err(format!(
474 "command timed out after {}s",
475 ctx.shell_timeout.as_secs()
476 )))
477 }
478 Ok(v) => v,
479 };
480 let status = match status {
481 Ok(s) => s,
482 Err(e) => return Ok(ToolOutput::err(format!("cannot run command: {e}"))),
483 };
484
485 let mut body = String::new();
486 body.push_str(&String::from_utf8_lossy(&stdout));
487 if !stderr.is_empty() {
488 if !body.is_empty() && !body.ends_with('\n') {
489 body.push('\n');
490 }
491 body.push_str(&String::from_utf8_lossy(&stderr));
492 }
493 let mut body = truncate(body, "output");
496 if out_dropped || err_dropped {
497 if !body.is_empty() && !body.ends_with('\n') {
498 body.push('\n');
499 }
500 body.push_str(&format!(
501 "[output exceeded {MAX_OUTPUT_BYTES} bytes; the rest was discarded as it streamed. \
502 Redirect to a file and read it in pieces if more is needed.]"
503 ));
504 }
505 if body.trim().is_empty() {
506 body.push_str("(no output)");
507 }
508
509 let code = status.code().unwrap_or(-1);
510 if code != 0 {
511 body = format!("exit status {code}\n{body}");
512 }
513 Ok(ToolOutput {
514 content: body,
515 is_error: code != 0,
516 external: false,
517 })
518 }
519}
520
521async fn drain_capped(
526 pipe: Option<impl tokio::io::AsyncRead + Unpin>,
527 cap: usize,
528) -> (Vec<u8>, bool) {
529 use tokio::io::AsyncReadExt;
530 let Some(mut pipe) = pipe else {
531 return (Vec::new(), false);
532 };
533 let mut kept = Vec::new();
534 let mut dropped = false;
535 let mut buf = [0u8; 8192];
536 loop {
537 match pipe.read(&mut buf).await {
538 Ok(0) | Err(_) => break,
539 Ok(n) => {
540 let take = n.min(cap.saturating_sub(kept.len()));
541 kept.extend_from_slice(&buf[..take]);
542 dropped |= take < n;
543 }
544 }
545 }
546 (kept, dropped)
547}
548
549pub struct HttpFetch;
550
551#[async_trait]
552impl Tool for HttpFetch {
553 fn name(&self) -> &str {
554 "http_fetch"
555 }
556
557 fn description(&self) -> &str {
558 "Fetch a URL over HTTP(S) and return the response body as text."
559 }
560
561 fn input_schema(&self) -> Value {
562 json!({
563 "type": "object",
564 "properties": {
565 "url": {"type": "string"}
566 },
567 "required": ["url"]
568 })
569 }
570
571 fn read_only(&self) -> bool {
572 true
575 }
576
577 fn capabilities(&self) -> Capabilities {
578 Capabilities::default().untrusted().sends()
579 }
580
581 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
582 let url = arg_str(&input, "url")?;
583 let vetted = match check_url(url, ctx).await {
584 Ok(v) => v,
585 Err(e) => return Ok(ToolOutput::err(e.to_string())),
586 };
587
588 let mut builder = reqwest::Client::builder()
589 .timeout(std::time::Duration::from_secs(30))
590 .redirect(reqwest::redirect::Policy::none());
593 if let Some((host, addrs)) = &vetted {
598 builder = builder.resolve_to_addrs(host, addrs);
599 }
600 let client = builder.build()?;
601 let resp = match client.get(url).send().await {
602 Ok(r) => r,
603 Err(e) => return Ok(ToolOutput::err(format!("request failed: {e}"))),
604 };
605
606 if resp.status().is_redirection() {
607 let target = resp
608 .headers()
609 .get("location")
610 .and_then(|v| v.to_str().ok())
611 .unwrap_or("(no location header)");
612 return Ok(ToolOutput::err(format!(
613 "{} redirect to {target} — not followed. Call http_fetch again with that URL if you want it.",
614 resp.status()
615 )));
616 }
617
618 let status = resp.status();
619 let mut raw: Vec<u8> = Vec::new();
623 let mut body_stream = resp.bytes_stream();
624 while let Some(chunk) = body_stream.next().await {
625 match chunk {
626 Ok(c) => raw.extend_from_slice(&c),
627 Err(e) => {
628 return Ok(ToolOutput::err(format!(
629 "reading the response body failed: {e}"
630 )))
631 }
632 }
633 if raw.len() > MAX_OUTPUT_BYTES {
634 break;
635 }
636 }
637 let body = String::from_utf8_lossy(&raw);
638 Ok(ToolOutput {
641 content: truncate(format!("HTTP {status}\n\n{body}"), "body"),
642 is_error: !status.is_success(),
643 external: true,
644 })
645 }
646}
647
648async fn check_url(
657 url: &str,
658 ctx: &ToolCtx,
659) -> Result<Option<(String, Vec<std::net::SocketAddr>)>> {
660 let parsed = reqwest::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid url: {e}"))?;
661 match parsed.scheme() {
662 "http" | "https" => {}
663 other => anyhow::bail!("scheme {other:?} is not allowed (use http or https)"),
664 }
665 let host = parsed
666 .host_str()
667 .ok_or_else(|| anyhow::anyhow!("url has no host"))?
668 .to_ascii_lowercase();
669
670 let policy = &ctx.security;
671 let matches = |pattern: &str| {
672 let pattern = pattern.trim_start_matches('.').to_ascii_lowercase();
673 host == pattern || host.ends_with(&format!(".{pattern}"))
674 };
675
676 if policy.blocked_domains.iter().any(|d| matches(d)) {
677 anyhow::bail!("{host} is on the blocked-domain list");
678 }
679 if !policy.allowed_domains.is_empty() && !policy.allowed_domains.iter().any(|d| matches(d)) {
680 anyhow::bail!("{host} is not on the allowed-domain list");
681 }
682
683 if policy.block_private_ips {
684 let port = parsed.port_or_known_default().unwrap_or(80);
687 let addrs = tokio::net::lookup_host((host.as_str(), port))
688 .await
689 .map_err(|e| anyhow::anyhow!("cannot resolve {host}: {e}"))?;
690
691 let mut vetted = Vec::new();
692 for addr in addrs {
693 if is_internal(&addr.ip()) {
694 anyhow::bail!(
695 "{host} resolves to the internal address {} — refused",
696 addr.ip()
697 );
698 }
699 vetted.push(addr);
700 }
701 if vetted.is_empty() {
702 anyhow::bail!("{host} did not resolve to any address");
703 }
704 return Ok(Some((host, vetted)));
705 }
706
707 Ok(None)
708}
709
710fn is_internal(ip: &std::net::IpAddr) -> bool {
712 use std::net::IpAddr;
713 match ip {
714 IpAddr::V4(v4) => {
715 v4.is_loopback()
716 || v4.is_private()
717 || v4.is_link_local()
719 || v4.is_broadcast()
720 || v4.is_documentation()
721 || v4.is_unspecified()
722 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
724 || v4.octets()[0] == 0
726 }
727 IpAddr::V6(v6) => {
728 v6.is_loopback()
729 || v6.is_unspecified()
730 || (v6.segments()[0] & 0xfe00) == 0xfc00
732 || (v6.segments()[0] & 0xffc0) == 0xfe80
733 || v6.to_ipv4_mapped().is_some_and(|v4| is_internal(&IpAddr::V4(v4)))
735 }
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::sandbox::{Backend, SandboxConfig};
743 use crate::tool::ToolCtx;
744
745 fn shell_with(kind: Backend, network: bool) -> Shell {
746 Shell::new(Arc::new(Sandbox::new(SandboxConfig {
747 kind,
748 network,
749 ..SandboxConfig::default()
750 })))
751 }
752
753 #[test]
754 fn confining_the_shell_closes_the_send_route_and_nothing_else() {
755 let loose = shell_with(Backend::None, false).capabilities();
756 assert!(loose.private_data && loose.external_send && loose.destructive);
757
758 let confined = shell_with(Backend::Bwrap, false).capabilities();
761 assert!(!confined.external_send, "no network means no way out");
762 assert!(confined.destructive, "it can still destroy the workspace");
763
764 assert!(
766 shell_with(Backend::Bwrap, true)
767 .capabilities()
768 .external_send
769 );
770 }
771
772 #[test]
773 fn the_remedy_names_the_condition_that_set_the_bit() {
774 let r = shell_with(Backend::None, false).denial_remedy().unwrap();
777 assert!(r.contains("[sandbox]") && r.contains("network = false"));
778
779 let r = shell_with(Backend::Bwrap, true).denial_remedy().unwrap();
783 assert!(r.contains("network = false"));
784 assert!(
785 !r.contains("kind ="),
786 "the sandbox is on; do not advise enabling it"
787 );
788
789 assert!(shell_with(Backend::Docker, false).denial_remedy().is_none());
793 }
794
795 #[test]
796 fn a_confined_shell_is_still_private_because_it_still_reads_your_files() {
797 for (kind, network) in [
803 (Backend::None, false),
804 (Backend::Bwrap, false),
805 (Backend::Docker, false),
806 ] {
807 assert!(
808 shell_with(kind, network).capabilities().private_data,
809 "{kind:?} shell reads the workspace, exactly as fs_read does"
810 );
811 }
812 assert!(
813 FsRead.capabilities().private_data,
814 "the rule this is matching"
815 );
816 }
817
818 fn ctx(dir: &std::path::Path) -> ToolCtx {
819 ToolCtx {
820 workspace: dir.to_path_buf(),
821 shell_timeout: std::time::Duration::from_secs(5),
822 ..Default::default()
823 }
824 }
825
826 #[tokio::test]
827 async fn escaping_the_workspace_is_refused() {
828 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
829 std::fs::create_dir_all(&dir).unwrap();
830 let ctx = ctx(&dir);
831
832 assert!(ctx.resolve("../../etc/passwd").is_err());
833 assert!(ctx.resolve("/etc/passwd").is_err());
834 assert!(ctx.resolve("notes.md").is_ok());
835
836 std::fs::remove_dir_all(&dir).ok();
837 }
838
839 #[tokio::test]
840 async fn a_command_that_floods_stdout_is_capped_not_buffered() {
841 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
842 std::fs::create_dir_all(&dir).unwrap();
843 let shell = shell_with(Backend::None, false);
844
845 let out = shell
849 .call(
850 json!({"command": "yes flood | head -c 1000000"}),
851 &ctx(&dir),
852 )
853 .await
854 .unwrap();
855
856 assert!(!out.is_error, "{}", out.content);
857 assert!(
858 out.content.len() <= MAX_OUTPUT_BYTES + 300,
859 "kept {} bytes",
860 out.content.len()
861 );
862 assert!(out.content.contains("discarded"), "the cut must be named");
863
864 std::fs::remove_dir_all(&dir).ok();
865 }
866
867 #[tokio::test]
868 async fn an_oversized_response_body_is_cut_at_the_cap_not_buffered_whole() {
869 use tokio::io::AsyncWriteExt;
870
871 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
874 let addr = listener.local_addr().unwrap();
875 tokio::spawn(async move {
876 use tokio::io::AsyncReadExt;
877 let (mut sock, _) = listener.accept().await.unwrap();
878 let mut req = Vec::new();
881 let mut tmp = [0u8; 4096];
882 while !req.windows(4).any(|w| w == b"\r\n\r\n") {
883 match sock.read(&mut tmp).await {
884 Ok(0) | Err(_) => break,
885 Ok(n) => req.extend_from_slice(&tmp[..n]),
886 }
887 }
888 let body = vec![b'a'; 1_000_000];
889 let head = format!(
890 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
891 body.len()
892 );
893 let _ = sock.write_all(head.as_bytes()).await;
894 let _ = sock.write_all(&body).await;
895 let _ = sock.shutdown().await;
896 });
897
898 let ctx = ToolCtx {
900 security: crate::config::SecurityConfig {
901 block_private_ips: false,
902 ..Default::default()
903 },
904 ..ToolCtx::default()
905 };
906 let out = HttpFetch
907 .call(json!({"url": format!("http://{addr}/big")}), &ctx)
908 .await
909 .unwrap();
910
911 assert!(out.external, "not an HTTP response: {}", out.content);
912 assert!(
913 out.content.len() <= MAX_OUTPUT_BYTES + 300,
914 "kept {} bytes",
915 out.content.len()
916 );
917 assert!(out.content.contains("[truncated"), "the cut must be named");
918 }
919
920 #[tokio::test]
921 async fn internal_addresses_are_refused_and_public_ones_come_back_pinned() {
922 let ctx = ToolCtx::default();
923
924 for url in [
926 "http://localhost/x",
927 "http://127.0.0.1/x",
928 "http://169.254.169.254/meta",
929 ] {
930 let err = check_url(url, &ctx).await.unwrap_err().to_string();
931 assert!(err.contains("internal"), "{url}: {err}");
932 }
933
934 let vetted = check_url("http://93.184.216.34/x", &ctx).await.unwrap();
938 let (host, addrs) = vetted.expect("the private-IP guard is on, so there is a pin");
939 assert_eq!(host, "93.184.216.34");
940 assert_eq!(addrs, vec!["93.184.216.34:80".parse().unwrap()]);
941
942 let open = ToolCtx {
944 security: crate::config::SecurityConfig {
945 block_private_ips: false,
946 ..Default::default()
947 },
948 ..ToolCtx::default()
949 };
950 assert!(check_url("http://127.0.0.1/x", &open)
951 .await
952 .unwrap()
953 .is_none());
954 }
955
956 #[tokio::test]
957 async fn edit_refuses_ambiguous_matches() {
958 let dir = std::env::temp_dir().join(format!("mecha-test-{}", uuid::Uuid::new_v4()));
959 std::fs::create_dir_all(&dir).unwrap();
960 std::fs::write(dir.join("f.txt"), "a\na\n").unwrap();
961
962 let out = FsEdit
963 .call(json!({"path": "f.txt", "old": "a", "new": "b"}), &ctx(&dir))
964 .await
965 .unwrap();
966 assert!(out.is_error);
967 assert!(out.content.contains("appears 2 times"));
968
969 std::fs::remove_dir_all(&dir).ok();
970 }
971}