Skip to main content

mecha_core/tool/
mod.rs

1//! Tools: the things an agent can actually do.
2//!
3//! A tool is a name, a description, a JSON Schema, and an async function. The
4//! registry holds them; MCP servers and native Rust functions both land here as
5//! the same trait object, so the agent loop never learns the difference.
6
7pub mod ask;
8pub mod builtin;
9pub mod todo;
10
11use crate::config::{PermissionMode, SecurityConfig, ToolsConfig};
12use crate::message::ToolSpec;
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::Value;
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20#[derive(Debug, Clone)]
21pub struct ToolOutput {
22    pub content: String,
23    /// Returned to the model as `is_error: true` so it can recover rather than
24    /// treating the failure as a result.
25    pub is_error: bool,
26    /// True when this content actually came from outside the machine.
27    ///
28    /// Distinct from the tool's declared `untrusted_input` capability, which
29    /// says what the tool *can* return. A refusal generated by mecha's own
30    /// guards is not third-party content, and labelling it as such makes the
31    /// model invent explanations for its own harness's behaviour.
32    pub external: bool,
33}
34
35impl ToolOutput {
36    pub fn ok(content: impl Into<String>) -> Self {
37        ToolOutput {
38            content: content.into(),
39            is_error: false,
40            external: false,
41        }
42    }
43
44    pub fn err(content: impl Into<String>) -> Self {
45        ToolOutput {
46            content: content.into(),
47            is_error: true,
48            external: false,
49        }
50    }
51
52    /// Mark this content as having come from outside the machine.
53    pub fn from_outside(mut self) -> Self {
54        self.external = true;
55        self
56    }
57}
58
59/// What a tool can do — the vocabulary MCP standardized (`readOnly`,
60/// `destructive`, `openWorld`) plus the two axes that decide whether an agent
61/// can be turned into an exfiltration tool.
62///
63/// The *lethal trifecta* is private data + untrusted content + a way out. Any
64/// agent holding all three can be instructed, by text hidden in the content it
65/// reads, to take the private data and send it somewhere. Annotating tools on
66/// these axes is what lets the loop refuse that combination structurally.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct Capabilities {
69    /// Returns data the user considers private.
70    pub private_data: bool,
71    /// Returns content a third party can influence — a web page, an email body,
72    /// a calendar invite title. Treat everything it returns as hostile.
73    pub untrusted_input: bool,
74    /// Can transmit data outside the user's control. Note that a plain HTTP GET
75    /// qualifies: the secret goes in the query string.
76    pub external_send: bool,
77    /// May destroy or overwrite data.
78    pub destructive: bool,
79}
80
81impl Capabilities {
82    pub fn private(mut self) -> Self {
83        self.private_data = true;
84        self
85    }
86    pub fn untrusted(mut self) -> Self {
87        self.untrusted_input = true;
88        self
89    }
90    pub fn sends(mut self) -> Self {
91        self.external_send = true;
92        self
93    }
94    pub fn destructive(mut self) -> Self {
95        self.destructive = true;
96        self
97    }
98
99    /// Everything either side declares.
100    ///
101    /// Union rather than assignment, because the only safe direction for an
102    /// override is *wider*. Letting config narrow a tool's declared
103    /// capabilities would disarm the interlock on the strength of a claim
104    /// nothing enforces — the same mistake as a sandbox that silently degrades,
105    /// and it would make the cheapest configuration the most dangerous one. A
106    /// server that genuinely over-declares is what `TrifectaPolicy` is for: one
107    /// deliberate, visible decision instead of a quiet per-server exemption.
108    pub fn union(self, other: Capabilities) -> Self {
109        Capabilities {
110            private_data: self.private_data || other.private_data,
111            untrusted_input: self.untrusted_input || other.untrusted_input,
112            external_send: self.external_send || other.external_send,
113            destructive: self.destructive || other.destructive,
114        }
115    }
116}
117
118#[async_trait]
119pub trait Tool: Send + Sync {
120    fn name(&self) -> &str;
121    fn description(&self) -> &str;
122    fn input_schema(&self) -> Value;
123
124    /// Read-only tools skip the approval gate and are safe to run in parallel.
125    fn read_only(&self) -> bool {
126        false
127    }
128
129    /// Declared risk surface. The default is the conservative one for a tool
130    /// nobody has classified: assume it does nothing special.
131    fn capabilities(&self) -> Capabilities {
132        Capabilities::default()
133    }
134
135    async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
136
137    /// State this tool holds that a compaction must not lose.
138    ///
139    /// Compaction replaces the middle of a transcript with prose, and the
140    /// measured failure mode is that a summariser preserves *what is true* and
141    /// drops *how far you got*. Some of "how far you got" does not live in the
142    /// messages at all — it lives in a tool — and for that state a summary is
143    /// the wrong mechanism twice over: it is lossy, and the tool already has
144    /// the exact current answer.
145    ///
146    /// So a tool may hand its state to the compaction to be carried across
147    /// **verbatim**. Three rules make this safe rather than a second source of
148    /// truth:
149    ///
150    /// - It is read at compaction time, so it is current by construction. A
151    ///   stale copy is impossible because nothing stores one.
152    /// - Exactly one copy survives: the carried block replaces the previous
153    ///   one rather than accumulating beside it, or an old list would sit in
154    ///   the prompt contradicting the new one.
155    /// - It is for state the tool *owns*, not a summary of what happened. A
156    ///   tool that returned prose here would be smuggling a second summariser
157    ///   into the loop, unvalidated.
158    ///
159    /// `None` — the default — means "nothing worth carrying", which is the
160    /// honest answer for every stateless tool.
161    fn carried_state(&self) -> Option<CarriedState> {
162        None
163    }
164
165    fn spec(&self) -> ToolSpec {
166        ToolSpec {
167            name: self.name().to_string(),
168            description: self.description().to_string(),
169            input_schema: self.input_schema(),
170        }
171    }
172}
173
174/// A tool's own state, on its way across a compaction.
175///
176/// `label` names it in the rebuilt prompt (the tool's name is the obvious
177/// choice); `body` is reproduced exactly, because verbatim is the whole point.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct CarriedState {
180    pub label: String,
181    pub body: String,
182}
183
184/// What a tool is allowed to touch.
185#[derive(Debug, Clone)]
186pub struct ToolCtx {
187    /// Filesystem tools refuse paths outside this root.
188    pub workspace: PathBuf,
189    pub shell_timeout: std::time::Duration,
190    pub security: SecurityConfig,
191    /// The byte budget one *turn's* tool results share, divided equally
192    /// across the calls in the batch so one runaway tool cannot starve its
193    /// siblings (mecha executes a turn's calls concurrently, so they land
194    /// together). The old per-tool cap was 200 KB — ~50k tokens, 1.5× the
195    /// whole local context window, which is not a cap so much as a promise
196    /// to overflow.
197    pub output_budget_bytes: usize,
198    /// Where an oversized result is saved in full before its transcript copy
199    /// is cut. `None` disables spilling — the cut then names what was lost
200    /// instead of where to find it. Per-context on purpose: two eval cases
201    /// sharing one spill directory could read each other's output through it.
202    pub spill_dir: Option<PathBuf>,
203    /// The run's event channel, so a tool that *contains* a run — a subagent —
204    /// can surface its progress instead of going dark until it returns.
205    ///
206    /// Display-only, and treat it that way: any tool (including a third-party
207    /// MCP server's) can send fabricated events down this channel, so nothing
208    /// that matters may key off it. Conversation state, taint, and run
209    /// completion all come from the loop and the caller's join handle, never
210    /// from events. Stamped by [`Agent::run_in`] per run; `None` everywhere
211    /// nobody is watching (batch, eval).
212    ///
213    /// [`Agent::run_in`]: crate::agent::Agent::run_in
214    pub events: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::AgentEvent>>,
215    /// The run's cancellation token. A tool that contains a run passes it on,
216    /// so cancelling the parent actually cancels the child instead of politely
217    /// waiting out its entire run. Stamped by `Agent::run_in`, like `events`.
218    pub cancel: Option<tokio_util::sync::CancellationToken>,
219    /// The run's phase. A tool that contains a run passes it on, so delegation
220    /// is not the way to get a write executed from a planning run. Stamped by
221    /// `Agent::run_in`, like `events`.
222    pub phase: crate::agent::Phase,
223    /// The `tool_use` id of the call this context was built for. Stamped per
224    /// dispatch (only when `events` is watched), so a tool that contains a
225    /// run can tag its forwarded events with the call that spawned it — two
226    /// subagents running in parallel are otherwise indistinguishable to a
227    /// renderer.
228    pub call_id: Option<String>,
229    /// The conversation's taint as of this turn, stamped per dispatch when a
230    /// mailbox is attached. The conservative pre-gate value — it includes
231    /// what the *batch* can return, so a read and a `message_send` in one
232    /// turn cannot stamp a clean label on the outgoing message. `None` means
233    /// nobody stamped it, and a consumer must fail closed (treat it as fully
234    /// tainted): a subagent's context, or any run wired outside the loop,
235    /// must never pass as a clean sender by omission.
236    pub taint: Option<crate::agent::Taint>,
237}
238
239impl Default for ToolCtx {
240    fn default() -> Self {
241        ToolCtx {
242            workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
243            shell_timeout: std::time::Duration::from_secs(120),
244            security: SecurityConfig::default(),
245            output_budget_bytes: 24_000,
246            spill_dir: fresh_spill_dir(),
247            events: None,
248            cancel: None,
249            phase: crate::agent::Phase::default(),
250            call_id: None,
251            taint: None,
252        }
253    }
254}
255
256/// A spill directory no other context shares. Not created until first used.
257fn fresh_spill_dir() -> Option<PathBuf> {
258    Some(std::env::temp_dir().join(format!("mecha-spill-{}", uuid::Uuid::new_v4())))
259}
260
261impl ToolCtx {
262    /// The same policy pointed at a different root. Used to give one run — an
263    /// eval case, a batch item — its own isolated copy of a workspace without
264    /// rebuilding the agent around it. The spill directory is re-derived too:
265    /// a re-rooted context is a new isolation domain, and inheriting the old
266    /// one would let its runs read each other's spilled output.
267    pub fn with_workspace(&self, workspace: impl Into<PathBuf>) -> Self {
268        ToolCtx {
269            workspace: workspace.into(),
270            spill_dir: fresh_spill_dir(),
271            ..self.clone()
272        }
273    }
274
275    /// Resolve a model-supplied path against the workspace and prove it stays
276    /// inside. The path is untrusted input: `..`, symlinks, and absolute paths
277    /// all have to be checked after canonicalization, not before.
278    pub fn resolve(&self, raw: &str) -> Result<PathBuf> {
279        let candidate = {
280            let p = Path::new(raw);
281            if p.is_absolute() {
282                p.to_path_buf()
283            } else {
284                self.workspace.join(p)
285            }
286        };
287
288        // The file may not exist yet (a write), so canonicalize the nearest
289        // existing ancestor and re-append the rest.
290        let mut existing = candidate.as_path();
291        let mut trailing = Vec::new();
292        let canonical_root = loop {
293            match existing.canonicalize() {
294                Ok(c) => break c,
295                Err(_) => match existing.parent() {
296                    Some(parent) => {
297                        if let Some(name) = existing.file_name() {
298                            trailing.push(name.to_owned());
299                        }
300                        existing = parent;
301                    }
302                    None => anyhow::bail!("cannot resolve path {raw:?}"),
303                },
304            }
305        };
306        let mut resolved = canonical_root;
307        for part in trailing.iter().rev() {
308            resolved.push(part);
309        }
310
311        let root = self
312            .workspace
313            .canonicalize()
314            .unwrap_or_else(|_| self.workspace.clone());
315        if resolved.starts_with(&root) {
316            return Ok(resolved);
317        }
318        // The spill directory is the one sanctioned exception: oversized tool
319        // output is saved there, and the truncation marker tells the model to
320        // read the rest from exactly that path. Its contents are this
321        // context's own tool results, so nothing new becomes reachable.
322        if let Some(spill) = &self.spill_dir {
323            let spill_root = spill.canonicalize().unwrap_or_else(|_| spill.clone());
324            if resolved.starts_with(&spill_root) {
325                return Ok(resolved);
326            }
327        }
328        anyhow::bail!(
329            "path {raw:?} resolves outside the workspace ({})",
330            root.display()
331        )
332    }
333}
334
335/// Floor under a result's share of the turn budget. A wide batch must not
336/// starve every result down to a marker with no content: below this, the
337/// division stops and the total budget is allowed to overrun instead.
338pub const SPILL_FLOOR_BYTES: usize = 4_096;
339
340/// Cut an oversized tool result down to `cap` bytes, saving the full output
341/// where the model can get it back.
342///
343/// The marker is written for the model, and it names the recovery — a
344/// truncation notice that only says "gone" leaves the model to conclude the
345/// rest never existed, and the elision line number is what makes the recovery
346/// a single call instead of a scan. A failed spill degrades to a plain cut
347/// that says the output was *not* saved; losing the tail must never lose the
348/// run.
349pub fn cap_result(
350    content: String,
351    cap: usize,
352    spill_dir: Option<&Path>,
353    tool: &str,
354    id: &str,
355) -> String {
356    if content.len() <= cap {
357        return content;
358    }
359    // Cut on a char boundary, never mid-codepoint.
360    let mut cut = cap;
361    while cut > 0 && !content.is_char_boundary(cut) {
362        cut -= 1;
363    }
364    let head = &content[..cut];
365    // The line the elision starts on. A cut mid-line means that same line —
366    // re-reading from it overlaps a little, which is the right direction.
367    let line = head.matches('\n').count() + 1;
368    let total = content.len();
369
370    let saved = spill_dir.and_then(|dir| {
371        // Owner-only: spilled output is tool results in full — the same
372        // sensitivity as the transcript, sitting in the shared temp dir.
373        crate::create_private_dir(dir).ok()?;
374        // A random component, because the call id alone can collide: batch
375        // items and non-sandboxed eval cases share one context, and a local
376        // server under a pinned seed can hand identical requests identical
377        // call ids. A collision would silently overwrite, leaving one
378        // conversation's marker pointing at another conversation's content.
379        let tag = &uuid::Uuid::new_v4().to_string()[..8];
380        let file = dir.join(format!("{}-{}-{tag}.txt", safe_name(tool), safe_name(id)));
381        std::fs::write(&file, &content).ok()?;
382        Some(file)
383    });
384
385    match saved {
386        Some(path) => format!(
387            "{head}\n\n[truncated by the harness: showing the first {cut} of {total} bytes; \
388             the rest begins on line {line}. The full output is saved at {path} — continue \
389             with fs_read {{\"path\": \"{path}\", \"offset\": {line}}}, or search it with \
390             grep.]",
391            path = path.display()
392        ),
393        None => format!(
394            "{head}\n\n[truncated by the harness: {omitted} of {total} bytes were dropped \
395             from line {line} on, and the full output could not be saved. Narrow the \
396             request and re-run the tool if the rest is needed.]",
397            omitted = total - cut
398        ),
399    }
400}
401
402/// Tool names and call ids become file names; anything else becomes `-`.
403fn safe_name(s: &str) -> String {
404    s.chars()
405        .map(|c| {
406            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
407                c
408            } else {
409                '-'
410            }
411        })
412        .collect()
413}
414
415/// The decision an approver hands back for one pending call.
416///
417/// Two ways to say no, and the difference is load-bearing rather than
418/// cosmetic. The learning miner keys on the exact string `"Denied by the
419/// user:"` to find corrections worth learning from, so **a refusal that no
420/// human made must not wear that label**. `Blocked` is the machine's no — a
421/// permission mode, a policy, a remote prompt nobody answered — and the loop
422/// renders it as `"Blocked by policy:"`, joining `"Blocked by a hook:"` in
423/// the family of refusals the miner ignores.
424///
425/// Without the split, a read-only run's refusals and a 2am approval nobody was
426/// awake to answer both become training data attributed to a user who never
427/// spoke. It is the same mistake as mining a publish's changed path as a voice
428/// correction, and it was live in `ModeApprover` until a Slack approver needed
429/// to express "nobody answered" and found there was no way to.
430#[derive(Debug, Clone)]
431pub enum Decision {
432    Allow,
433    /// A human said no. The reason is passed to the model so it can pick
434    /// another approach — and it is mined as a correction.
435    Deny(String),
436    /// Machine policy said no, and no human was consulted. Never mined.
437    Blocked(String),
438}
439
440/// Gates tool calls that aren't read-only. The CLI implements this with a
441/// terminal prompt; a headless caller can auto-allow or auto-deny.
442#[async_trait]
443pub trait Approver: Send + Sync {
444    async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
445}
446
447/// Answers from the configured [`PermissionMode`] without asking anyone.
448pub struct ModeApprover {
449    pub mode: PermissionMode,
450}
451
452#[async_trait]
453impl Approver for ModeApprover {
454    async fn approve(&self, tool: &dyn Tool, _input: &Value) -> Decision {
455        match self.mode {
456            PermissionMode::Allow => Decision::Allow,
457            PermissionMode::ReadOnly if tool.read_only() => Decision::Allow,
458            // `Blocked`, not `Deny`: a permission mode is policy this run was
459            // started with, not a correction anybody made.
460            PermissionMode::ReadOnly => Decision::Blocked(format!(
461                "`{}` modifies state and this run is read-only",
462                tool.name()
463            )),
464            // Nothing is watching to answer, so the safe reading of "ask" is no.
465            PermissionMode::Ask => Decision::Blocked(format!(
466                "`{}` needs approval and this run is non-interactive (use --yes to allow)",
467                tool.name()
468            )),
469        }
470    }
471}
472
473#[derive(Default)]
474pub struct Registry {
475    tools: BTreeMap<String, Arc<dyn Tool>>,
476}
477
478impl Registry {
479    pub fn new() -> Self {
480        Self::default()
481    }
482
483    /// Register a tool. A later registration with the same name replaces the
484    /// earlier one, so MCP servers can shadow built-ins deliberately.
485    pub fn insert(&mut self, tool: Arc<dyn Tool>) {
486        self.tools.insert(tool.name().to_string(), tool);
487    }
488
489    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
490        self.tools.get(name)
491    }
492
493    pub fn is_empty(&self) -> bool {
494        self.tools.is_empty()
495    }
496
497    pub fn len(&self) -> usize {
498        self.tools.len()
499    }
500
501    pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
502        self.tools.values()
503    }
504
505    /// Everything the registered tools want carried across a compaction.
506    ///
507    /// In the registry's stable order, so a compaction does not reorder the
508    /// prompt for a reason nobody can see. Asked of every tool, including an
509    /// MCP server's — the loop does not learn which tools have state, only
510    /// that some do, which is the same reason it never learns where a tool
511    /// came from.
512    pub fn carried_state(&self) -> Vec<CarriedState> {
513        self.tools
514            .values()
515            .filter_map(|t| t.carried_state())
516            .collect()
517    }
518
519    /// Specs in a stable order — the tool list is the very front of the prompt
520    /// prefix, so reordering it would invalidate the cache on every request.
521    pub fn specs(&self) -> Vec<ToolSpec> {
522        self.tools.values().map(|t| t.spec()).collect()
523    }
524
525    /// Specs a given phase permits, in the same stable order.
526    ///
527    /// Note what this does to the prompt cache: planning sends a shorter tool
528    /// list, so switching phase changes the front of the prefix and the next
529    /// turn re-pays for it. That is the price of the tools being genuinely
530    /// absent rather than merely refused, and it is the right trade.
531    pub fn specs_for(&self, phase: crate::agent::Phase) -> Vec<ToolSpec> {
532        self.tools
533            .values()
534            .filter(|t| phase.allows(t.read_only()))
535            .map(|t| t.spec())
536            .collect()
537    }
538
539    /// Register the built-ins permitted by config.
540    ///
541    /// The sandbox is passed in rather than read from config here because it
542    /// changes what `shell` *is* — an unconfined shell and a confined one
543    /// declare different capabilities, and the loop's interlock reads them.
544    pub fn with_builtins(
545        mut self,
546        cfg: &ToolsConfig,
547        sandbox: Arc<crate::sandbox::Sandbox>,
548    ) -> Self {
549        for tool in builtin::all(sandbox) {
550            let name = tool.name();
551            let allowed = cfg.enabled.is_empty() || cfg.enabled.iter().any(|e| e == name);
552            let blocked = cfg.disabled.iter().any(|d| d == name);
553            if allowed && !blocked {
554                self.insert(tool);
555            }
556        }
557        self
558    }
559}
560
561#[cfg(test)]
562mod cap_tests {
563    use super::*;
564
565    fn scratch(name: &str) -> PathBuf {
566        let dir = std::env::temp_dir().join(format!("mecha-cap-{name}-{}", uuid::Uuid::new_v4()));
567        std::fs::create_dir_all(&dir).unwrap();
568        dir
569    }
570
571    #[test]
572    fn a_result_under_the_cap_is_untouched() {
573        let out = cap_result("short".into(), 100, None, "shell", "t1");
574        assert_eq!(out, "short");
575    }
576
577    #[test]
578    fn an_oversized_result_is_spilled_whole_and_the_marker_names_the_recovery() {
579        let dir = scratch("spill");
580        let body: String = (1..=100).map(|i| format!("line {i}\n")).collect();
581
582        let out = cap_result(body.clone(), 200, Some(&dir), "shell", "t1");
583
584        // The transcript copy is bounded...
585        assert!(out.len() < body.len());
586        assert!(out.starts_with("line 1\n"));
587        // ...the disk copy is not: byte-identical, so nothing was lost. The
588        // name carries a random tag, so it is discovered rather than assumed.
589        let file = std::fs::read_dir(&dir)
590            .unwrap()
591            .next()
592            .unwrap()
593            .unwrap()
594            .path();
595        assert!(file
596            .file_name()
597            .unwrap()
598            .to_str()
599            .unwrap()
600            .starts_with("shell-t1-"));
601        assert_eq!(std::fs::read_to_string(&file).unwrap(), body);
602
603        // The marker gives the model a single call back to the rest: the
604        // path, and the line the elision starts on.
605        let line = body[..200].matches('\n').count() + 1;
606        assert!(out.contains(&file.display().to_string()), "{out}");
607        assert!(out.contains(&format!("\"offset\": {line}")), "{out}");
608        assert!(out.contains("fs_read"), "the recovery must be named: {out}");
609
610        std::fs::remove_dir_all(&dir).ok();
611    }
612
613    #[test]
614    fn a_failed_spill_degrades_to_a_cut_that_admits_the_loss() {
615        // A directory that cannot exist: spilling fails, the run must not.
616        let impossible = PathBuf::from("/dev/null/not-a-dir");
617        let body = "x".repeat(1000);
618        let out = cap_result(body, 100, Some(&impossible), "shell", "t1");
619
620        assert!(out.contains("could not be saved"), "{out}");
621        assert!(
622            out.contains("re-run the tool"),
623            "the fallback still names a recovery: {out}"
624        );
625        assert!(
626            !out.contains("/dev/null"),
627            "no path is promised that does not exist"
628        );
629    }
630
631    #[test]
632    fn the_cut_lands_on_a_char_boundary() {
633        // A cap that falls mid-codepoint must back up, not panic.
634        let body = "é".repeat(100); // 2 bytes per char
635        let out = cap_result(body, 33, None, "shell", "t1");
636        assert!(out.starts_with(&"é".repeat(16)));
637    }
638
639    #[test]
640    fn the_jail_admits_the_spill_directory_and_nothing_else_new() {
641        let workspace = scratch("ws");
642        let spill = scratch("spilldir");
643        let ctx = ToolCtx {
644            workspace: workspace.clone(),
645            spill_dir: Some(spill.clone()),
646            ..ToolCtx::default()
647        };
648
649        // The marker names an absolute spill path; fs_read must be able to
650        // follow it, or the recovery the model was promised is a lie.
651        std::fs::write(spill.join("shell-t1.txt"), "spilled").unwrap();
652        let resolved = ctx
653            .resolve(&spill.join("shell-t1.txt").display().to_string())
654            .unwrap();
655        assert!(resolved.ends_with("shell-t1.txt"));
656
657        // The exception is the spill directory, not the temp dir around it.
658        let elsewhere = std::env::temp_dir().join("mecha-cap-elsewhere.txt");
659        std::fs::write(&elsewhere, "no").unwrap();
660        assert!(ctx.resolve(&elsewhere.display().to_string()).is_err());
661
662        // And with spilling disabled there is no exception at all.
663        let no_spill = ToolCtx {
664            workspace,
665            spill_dir: None,
666            ..ToolCtx::default()
667        };
668        assert!(no_spill
669            .resolve(&spill.join("shell-t1.txt").display().to_string())
670            .is_err());
671
672        std::fs::remove_dir_all(&spill).ok();
673        std::fs::remove_file(&elsewhere).ok();
674    }
675
676    #[test]
677    fn a_rerooted_context_gets_its_own_spill_directory() {
678        // Two eval cases sharing one spill directory could read each other's
679        // output through it — the same isolation rule as the workspace copy.
680        let ctx = ToolCtx::default();
681        let rerooted = ctx.with_workspace(std::env::temp_dir());
682        assert_ne!(ctx.spill_dir, rerooted.spill_dir);
683    }
684}