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                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        // Ambiguity here silently edits the wrong line, so refuse instead.
214        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
286/// Runs commands, confined by whatever [`Sandbox`] it was built with.
287///
288/// The policy lives on the tool rather than in [`ToolCtx`] because it decides
289/// the tool's *capabilities*, and `capabilities()` has no context to consult.
290/// The workspace still comes from the context at call time, so per-run jails —
291/// an eval case's private copy of a fixture — are confined to that copy.
292pub 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        // Unconfined, `shell` is universal: it reads your machine, it can
326        // `curl` data out, and it can delete things. Taint tracking cannot see
327        // inside a command, so it is deliberately NOT marked as an untrusted
328        // *source* — the mitigation for that is the sandbox, not a label.
329        //
330        // Confined without a network, it stops being an exfiltration route, and
331        // *that* is the claim the sandbox earns: the interlock can stop
332        // refusing outbound-looking work that provably cannot go anywhere. It
333        // narrows only because something else enforces it — see
334        // `Sandbox::preflight`, which refuses to start if it doesn't.
335        //
336        // `private_data` stays true regardless. A confined shell still reads
337        // the workspace, and `fs_read` — which reads exactly the same files —
338        // is marked private on the grounds that your files are the definition
339        // of private data. Narrowing it here would open a hole rather than
340        // close one: `shell: cat secrets` would set no taint where
341        // `fs_read: secrets` does, and the cheapest way around the interlock
342        // would be to use the more dangerous tool.
343        Capabilities {
344            private_data: true,
345            untrusted_input: false,
346            external_send: self.sandbox.can_reach_network(),
347            destructive: true,
348        }
349    }
350
351    // Which condition set `external_send` decides the remedy, and only this
352    // tool knows: no sandbox at all and a sandbox sharing the host's network
353    // produce the same capability bit with different one-line fixes. Confined
354    // without a network the bit is off, the interlock never fires on this
355    // tool, and there is rightly nothing to say.
356    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            // Before the generic network branch, because a landlocked shell
367            // reports reachable regardless of `network`, and "set network =
368            // false" would be advice the operator may have already followed.
369            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        // A sandbox that cannot be built refuses the call. Running the command
397        // unconfined instead would silently break the promise the capabilities
398        // above are making on its behalf.
399        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        // Streams are drained with a cap rather than collected with
411        // `output()`: a command can print without bound, and the harness must
412        // not buffer without bound on its behalf. `kill_on_drop` also closes
413        // an older hole — without it, a timed-out command kept running after
414        // the run had reported it dead.
415        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        // Bound the transcript copy *before* naming the discard: `truncate`
458        // cuts the tail, and the tail is exactly where the marker goes.
459        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
485/// Read a child's stream to EOF, keeping at most `cap` bytes and discarding
486/// the rest as it arrives. Discarding matters as much as capping: a reader
487/// that simply stopped would fill the pipe and deadlock the child against a
488/// harness that has already decided not to keep the output.
489async 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        // Read-only with respect to *your* data — but see `capabilities`: a GET
537        // is also an exfiltration channel, because the payload fits in the URL.
538        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            // Following a redirect re-opens everything check_url just closed:
555            // a public host can 302 straight to 169.254.169.254.
556            .redirect(reqwest::redirect::Policy::none());
557        // Pin the connection to the addresses that passed the private-IP
558        // check. Without this the client re-resolves the hostname itself, and
559        // a DNS answer with TTL 0 can hand the check a public address and the
560        // connection 169.254.169.254 — the classic rebinding TOCTOU.
561        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        // Read at most one byte past the cap, then stop — `.text()` buffers
584        // however much the server chooses to send, and the server is the
585        // untrusted side of this call. `truncate` below marks the cut.
586        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        // The body is third-party content even on a 4xx — an injection hides
603        // just as well in an error page.
604        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
612/// Refuse a URL before any packet leaves. Model output decides where this
613/// request goes, so "it's just a GET" is not a defense: the LAN, localhost,
614/// and cloud metadata endpoints are all reachable from here by default.
615///
616/// Returns the host and the addresses that passed the private-IP check, so
617/// the caller can pin the connection to exactly those — a check that lets the
618/// client resolve again afterwards is only advice. `None` when the private-IP
619/// guard is off and there is nothing to pin.
620async 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        // Resolve first and check every address: a hostname under the
649        // attacker's control can point at 127.0.0.1 or the metadata service.
650        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
674/// Addresses an agent has no business reaching on the user's behalf.
675fn 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                // 169.254.0.0/16 — includes the cloud metadata endpoint.
682                || v4.is_link_local()
683                || v4.is_broadcast()
684                || v4.is_documentation()
685                || v4.is_unspecified()
686                // 100.64.0.0/10, carrier-grade NAT and tailnets.
687                || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
688                // 0.0.0.0/8
689                || v4.octets()[0] == 0
690        }
691        IpAddr::V6(v6) => {
692            v6.is_loopback()
693                || v6.is_unspecified()
694                // fc00::/7 unique-local, fe80::/10 link-local.
695                || (v6.segments()[0] & 0xfe00) == 0xfc00
696                || (v6.segments()[0] & 0xffc0) == 0xfe80
697                // IPv4-mapped addresses must be checked as IPv4.
698                || 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        // The one thing the sandbox earns: with no network, a command cannot
723        // carry anything off the machine, so it is no longer a trifecta sink.
724        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        // Confined *with* a network is a way out again.
729        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        // Unconfined: the fix is enabling the sandbox, and the remedy must
739        // say so concretely enough to act on — section and the flag.
740        let r = shell_with(Backend::None, false).denial_remedy().unwrap();
741        assert!(r.contains("[sandbox]") && r.contains("network = false"));
742
743        // Confined but sharing the host network: the sandbox is already on,
744        // so advising the operator to enable it would be the same class of
745        // dead-end advice this method exists to end. Only the flag.
746        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        // Confined without a network: external_send is off, the interlock
754        // never fires on this tool, and a remedy here would be advice for a
755        // refusal that cannot happen.
756        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        // The hole this guards: if a sandboxed `shell` stopped counting as
762        // private, `shell: cat secrets.txt` would set no taint while
763        // `fs_read: secrets.txt` — the same bytes, the safer tool — would. The
764        // cheapest route around the interlock must never be the more dangerous
765        // tool.
766        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        // 1 MB of output against a 200 KB cap. The old `output()` collector
810        // kept all of it in memory; a `yes` running to the timeout kept
811        // gigabytes.
812        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        // A local server that answers with 1 MB. The client must stop reading
836        // at the cap — `.text()` would buffer whatever the server sent.
837        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            // Read the request head before answering, so the client never
843            // sees a response racing its own send.
844            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        // Loopback is the test server, so the private-IP guard steps aside.
863        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        // Names and literals that resolve internally are refused outright.
889        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        // A public literal passes, and the vetted addresses come back so the
899        // caller can pin the connection to them — returning only Ok(()) here
900        // is the rebinding hole: the client would resolve again on its own.
901        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        // With the guard off there is nothing to pin — the old behaviour.
907        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}