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}