Skip to main content

mecha_core/tool/
builtin.rs

1//! Built-in tools. No server required — these are ordinary Rust functions.
2//!
3//! Every filesystem path here arrives as model output, so it goes through
4//! [`ToolCtx::resolve`] before it reaches the filesystem. Shell commands are
5//! likewise untrusted: they run under the approval gate, not around it.
6
7use 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
27/// Model output can be enormous; truncate at a size that stays readable in
28/// context instead of blowing the window on one file.
29const 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        // Your files are the definition of private data.
84        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                // **A binary file is a different failure and has to say so.**
93                // `read_to_string` reports "stream did not contain valid
94                // UTF-8", which names the mechanism and not the problem, so a
95                // model handed a screenshot reads it as "that read failed"
96                // and retries the same idea through `shell` — measured
97                // 2026-08-21, where `file` and `ls -la` each cost the user an
98                // approval card on a phone before the run concluded what the
99                // first error already knew. The convention this repository
100                // states for tool errors is that the error says what to do
101                // instead; this one said nothing.
102                //
103                // Named by extension rather than by sniffing, because the
104                // answer only has to be good enough to redirect: what a model
105                // needs here is "stop trying to read this as text", and for
106                // an image, who *can* look at it.
107                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        // Ambiguity here silently edits the wrong line, so refuse instead.
250        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
322/// Runs commands, confined by whatever [`Sandbox`] it was built with.
323///
324/// The policy lives on the tool rather than in [`ToolCtx`] because it decides
325/// the tool's *capabilities*, and `capabilities()` has no context to consult.
326/// The workspace still comes from the context at call time, so per-run jails —
327/// an eval case's private copy of a fixture — are confined to that copy.
328pub 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        // Unconfined, `shell` is universal: it reads your machine, it can
362        // `curl` data out, and it can delete things. Taint tracking cannot see
363        // inside a command, so it is deliberately NOT marked as an untrusted
364        // *source* — the mitigation for that is the sandbox, not a label.
365        //
366        // Confined without a network, it stops being an exfiltration route, and
367        // *that* is the claim the sandbox earns: the interlock can stop
368        // refusing outbound-looking work that provably cannot go anywhere. It
369        // narrows only because something else enforces it — see
370        // `Sandbox::preflight`, which refuses to start if it doesn't.
371        //
372        // `private_data` stays true regardless. A confined shell still reads
373        // the workspace, and `fs_read` — which reads exactly the same files —
374        // is marked private on the grounds that your files are the definition
375        // of private data. Narrowing it here would open a hole rather than
376        // close one: `shell: cat secrets` would set no taint where
377        // `fs_read: secrets` does, and the cheapest way around the interlock
378        // would be to use the more dangerous tool.
379        Capabilities {
380            private_data: true,
381            untrusted_input: false,
382            external_send: self.sandbox.can_reach_network(),
383            destructive: true,
384        }
385    }
386
387    // Which condition set `external_send` decides the remedy, and only this
388    // tool knows: no sandbox at all and a sandbox sharing the host's network
389    // produce the same capability bit with different one-line fixes. Confined
390    // without a network the bit is off, the interlock never fires on this
391    // tool, and there is rightly nothing to say.
392    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            // Before the generic network branch, because a landlocked shell
403            // reports reachable regardless of `network`, and "set network =
404            // false" would be advice the operator may have already followed.
405            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        // A sandbox that cannot be built refuses the call. Running the command
433        // unconfined instead would silently break the promise the capabilities
434        // above are making on its behalf.
435        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        // Streams are drained with a cap rather than collected with
447        // `output()`: a command can print without bound, and the harness must
448        // not buffer without bound on its behalf. `kill_on_drop` also closes
449        // an older hole — without it, a timed-out command kept running after
450        // the run had reported it dead.
451        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        // Bound the transcript copy *before* naming the discard: `truncate`
494        // cuts the tail, and the tail is exactly where the marker goes.
495        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
521/// Read a child's stream to EOF, keeping at most `cap` bytes and discarding
522/// the rest as it arrives. Discarding matters as much as capping: a reader
523/// that simply stopped would fill the pipe and deadlock the child against a
524/// harness that has already decided not to keep the output.
525async 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        // Read-only with respect to *your* data — but see `capabilities`: a GET
573        // is also an exfiltration channel, because the payload fits in the URL.
574        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            // Following a redirect re-opens everything check_url just closed:
591            // a public host can 302 straight to 169.254.169.254.
592            .redirect(reqwest::redirect::Policy::none());
593        // Pin the connection to the addresses that passed the private-IP
594        // check. Without this the client re-resolves the hostname itself, and
595        // a DNS answer with TTL 0 can hand the check a public address and the
596        // connection 169.254.169.254 — the classic rebinding TOCTOU.
597        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        // Read at most one byte past the cap, then stop — `.text()` buffers
620        // however much the server chooses to send, and the server is the
621        // untrusted side of this call. `truncate` below marks the cut.
622        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        // The body is third-party content even on a 4xx — an injection hides
639        // just as well in an error page.
640        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
648/// Refuse a URL before any packet leaves. Model output decides where this
649/// request goes, so "it's just a GET" is not a defense: the LAN, localhost,
650/// and cloud metadata endpoints are all reachable from here by default.
651///
652/// Returns the host and the addresses that passed the private-IP check, so
653/// the caller can pin the connection to exactly those — a check that lets the
654/// client resolve again afterwards is only advice. `None` when the private-IP
655/// guard is off and there is nothing to pin.
656async 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        // Resolve first and check every address: a hostname under the
685        // attacker's control can point at 127.0.0.1 or the metadata service.
686        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
710/// Addresses an agent has no business reaching on the user's behalf.
711fn 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                // 169.254.0.0/16 — includes the cloud metadata endpoint.
718                || v4.is_link_local()
719                || v4.is_broadcast()
720                || v4.is_documentation()
721                || v4.is_unspecified()
722                // 100.64.0.0/10, carrier-grade NAT and tailnets.
723                || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
724                // 0.0.0.0/8
725                || v4.octets()[0] == 0
726        }
727        IpAddr::V6(v6) => {
728            v6.is_loopback()
729                || v6.is_unspecified()
730                // fc00::/7 unique-local, fe80::/10 link-local.
731                || (v6.segments()[0] & 0xfe00) == 0xfc00
732                || (v6.segments()[0] & 0xffc0) == 0xfe80
733                // IPv4-mapped addresses must be checked as IPv4.
734                || 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        // The one thing the sandbox earns: with no network, a command cannot
759        // carry anything off the machine, so it is no longer a trifecta sink.
760        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        // Confined *with* a network is a way out again.
765        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        // Unconfined: the fix is enabling the sandbox, and the remedy must
775        // say so concretely enough to act on — section and the flag.
776        let r = shell_with(Backend::None, false).denial_remedy().unwrap();
777        assert!(r.contains("[sandbox]") && r.contains("network = false"));
778
779        // Confined but sharing the host network: the sandbox is already on,
780        // so advising the operator to enable it would be the same class of
781        // dead-end advice this method exists to end. Only the flag.
782        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        // Confined without a network: external_send is off, the interlock
790        // never fires on this tool, and a remedy here would be advice for a
791        // refusal that cannot happen.
792        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        // The hole this guards: if a sandboxed `shell` stopped counting as
798        // private, `shell: cat secrets.txt` would set no taint while
799        // `fs_read: secrets.txt` — the same bytes, the safer tool — would. The
800        // cheapest route around the interlock must never be the more dangerous
801        // tool.
802        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        // 1 MB of output against a 200 KB cap. The old `output()` collector
846        // kept all of it in memory; a `yes` running to the timeout kept
847        // gigabytes.
848        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        // A local server that answers with 1 MB. The client must stop reading
872        // at the cap — `.text()` would buffer whatever the server sent.
873        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            // Read the request head before answering, so the client never
879            // sees a response racing its own send.
880            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        // Loopback is the test server, so the private-IP guard steps aside.
899        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        // Names and literals that resolve internally are refused outright.
925        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        // A public literal passes, and the vetted addresses come back so the
935        // caller can pin the connection to them — returning only Ok(()) here
936        // is the rebinding hole: the client would resolve again on its own.
937        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        // With the guard off there is nothing to pin — the old behaviour.
943        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}