Skip to main content

leviath_tools/
lib.rs

1//! Native built-in tools for Leviath agents.
2//!
3//! Provides file system and shell tools sandboxed to a working directory.
4
5use leviath_core::resolves_within;
6use leviath_providers::Tool;
7use serde_json::{Value, json};
8use std::collections::{HashMap, HashSet};
9use std::path::{Component, Path, PathBuf};
10use std::sync::{Arc, Mutex, PoisonError};
11use tokio::process::Command;
12use tokio::time::{Duration, timeout};
13
14// The tool families, one module per concern; lib.rs keeps the struct and
15// its constructors.
16mod context;
17mod defs;
18mod exec;
19pub use exec::is_null_device;
20mod platform;
21pub mod validate;
22pub use context::*;
23pub use defs::{SUBAGENT_TOOLS, is_subagent_tool, submit_output_description};
24pub use platform::*;
25pub use validate::*;
26
27/// The tool an agent calls to hand back the run's final output.
28///
29/// Re-exported from `leviath-core`, which owns the name because the blueprint
30/// validator and the manifest parser both need it and neither may depend on
31/// this crate.
32pub use leviath_core::blueprint::SUBMIT_OUTPUT_TOOL;
33
34/// Built-in tools: read_file, write_file, edit_file, list_dir, shell.
35///
36/// Carries the [`PlatformCapabilities`] of the current platform; tools whose
37/// [`tool_required_capabilities`] aren't satisfied are dropped from
38/// [`tool_defs`](Self::tool_defs), [`names`](Self::names), and rejected by
39/// [`execute`](Self::execute).
40pub struct BuiltinTools {
41    ctx: ToolContext,
42    platform: PlatformCapabilities,
43    /// When set, shell commands run through this sandbox instead of the host.
44    shell_executor: Option<Arc<dyn ShellExecutor>>,
45}
46
47impl BuiltinTools {
48    /// Create a new BuiltinTools instance with the given sandbox context,
49    /// filtering tools against the current platform's capabilities.
50    pub fn new(ctx: ToolContext) -> Self {
51        Self {
52            ctx,
53            platform: PlatformCapabilities::current(),
54            shell_executor: None,
55        }
56    }
57
58    /// The directory every path these tools resolve is confined to, already
59    /// canonicalized.
60    ///
61    /// Exposed so the authorization layer can hold a *shell redirect* to the
62    /// same fence `resolve` already holds `write_file` to. Without it the two
63    /// disagree, and `> path` becomes the spelling of `write_file` that works.
64    ///
65    /// Canonical rather than as-supplied, because that is what the fence
66    /// compares against: on macOS a `/var/...` workdir resolves to
67    /// `/private/var/...`, and handing out the former would refuse every write
68    /// in the workspace.
69    pub fn workdir(&self) -> &Path {
70        &self.ctx.workdir
71    }
72
73    /// Route this agent's shell execution through `executor` (a container /
74    /// namespace sandbox) instead of the host.
75    pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
76        self.shell_executor = Some(executor);
77        self
78    }
79
80    /// Create a BuiltinTools instance with an explicit platform capability set,
81    /// for tests or hosts that need to override the compile-time default.
82    pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
83        Self {
84            ctx,
85            platform,
86            shell_executor: None,
87        }
88    }
89
90    /// Whether a built-in named `canonical_name` is available on this platform.
91    fn available(&self, canonical_name: &str) -> bool {
92        self.platform
93            .satisfies(tool_required_capabilities(canonical_name))
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::fs;
101
102    fn make_tools(dir: &std::path::Path) -> BuiltinTools {
103        BuiltinTools::new(ToolContext::new(dir.to_path_buf()))
104    }
105
106    /// The accessor the authorization layer holds shell redirects against, so
107    /// `> path` answers to the same fence `resolve` holds `write_file` to.
108    ///
109    /// It reports the *canonicalized* directory, which is the point rather than
110    /// an accident: `resolves_within` canonicalizes what it is given, so a
111    /// workdir that came back uncanonicalized would compare `/var/...` against
112    /// `/private/var/...` on macOS and refuse every write in the workspace.
113    #[test]
114    fn workdir_reports_the_canonical_directory_the_tools_were_built_over() {
115        let dir = tempfile::tempdir().unwrap();
116        let tools = make_tools(dir.path());
117        let canonical = std::fs::canonicalize(dir.path()).unwrap();
118        assert_eq!(tools.workdir(), canonical);
119        // And it really is inside itself by the predicate the fence uses, which
120        // is the property the accessor exists to serve.
121        assert!(leviath_core::resolves_within(
122            &tools.workdir().join("out.txt"),
123            tools.workdir()
124        ));
125    }
126
127    /// Built-ins over a mobile capability set (no `ProcessSpawn`), so the
128    /// `shell` tool and its `bash` alias are filtered out.
129    fn make_mobile_tools(dir: &std::path::Path) -> BuiltinTools {
130        BuiltinTools::with_capabilities(
131            ToolContext::new(dir.to_path_buf()),
132            PlatformCapabilities::mobile(),
133        )
134    }
135
136    #[test]
137    fn the_shell_tool_advertises_the_shell_this_host_resolved() {
138        // Whichever shell the host has, the description has to name *it*: a
139        // model that reads "cmd" and gets zsh (or the reverse) writes the wrong
140        // commands, which is exactly the failure this replaced.
141        let dir = tempfile::tempdir().unwrap();
142        let defs = make_tools(dir.path()).tool_defs();
143        let shell = defs
144            .iter()
145            .find(|t| t.name == "shell")
146            .expect("shell is advertised on a desktop capability set");
147        let (resolved, _) = BuiltinTools::detect_shell();
148        assert!(
149            shell.description.contains(resolved),
150            "description {:?} does not name the resolved shell {resolved:?}",
151            shell.description
152        );
153
154        // Both platforms' wordings, without needing to run on both.
155        assert!(crate::defs::shell_tool_description("cmd.exe").contains("`cmd.exe`"));
156        assert!(crate::defs::shell_tool_description("/bin/zsh").contains("`/bin/zsh`"));
157    }
158
159    #[test]
160    fn subagent_predicate_covers_the_five_names_and_nothing_else() {
161        for name in SUBAGENT_TOOLS {
162            assert!(is_subagent_tool(name));
163        }
164        assert!(!is_subagent_tool("read_file"));
165    }
166
167    // ── Tool definitions ──────────────────────────────────────────────────
168
169    #[test]
170    fn tool_defs_returns_twenty_tools() {
171        let dir = std::env::temp_dir();
172        let tools = make_tools(&dir);
173        let defs = tools.tool_defs();
174        assert_eq!(defs.len(), 20);
175    }
176
177    #[test]
178    fn tool_defs_names_are_correct() {
179        let dir = std::env::temp_dir();
180        let tools = make_tools(&dir);
181        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
182        assert!(names.contains(&"read_file".to_string()));
183        assert!(names.contains(&"read_files".to_string()));
184        assert!(names.contains(&"write_file".to_string()));
185        assert!(names.contains(&"edit_file".to_string()));
186        assert!(names.contains(&"list_dir".to_string()));
187        assert!(names.contains(&"shell".to_string()));
188        assert!(names.contains(&"present_for_review".to_string()));
189        assert!(names.contains(&"ask_user_text".to_string()));
190        assert!(names.contains(&"ask_user_choice".to_string()));
191        assert!(names.contains(&"ask_user_confirm".to_string()));
192        assert!(names.contains(&"edit_document".to_string()));
193        assert!(names.contains(&"context_write".to_string()));
194        assert!(names.contains(&"context_append".to_string()));
195        assert!(names.contains(&"context_read".to_string()));
196        assert!(names.contains(&"context_delete".to_string()));
197        assert!(names.contains(&"context_list".to_string()));
198    }
199
200    #[test]
201    fn tool_defs_edit_document_requires_content() {
202        let dir = std::env::temp_dir();
203        let tools = make_tools(&dir);
204        let def = tools
205            .tool_defs()
206            .into_iter()
207            .find(|t| t.name == "edit_document")
208            .expect("edit_document tool def must exist");
209        let required = def.parameters["required"].as_array().unwrap();
210        assert!(required.iter().any(|v| v == "content"));
211        assert_eq!(def.parameters["properties"]["content"]["type"], "string");
212        // Also present in the builtin name list.
213        assert!(tools.names().contains(&"edit_document".to_string()));
214    }
215
216    #[test]
217    fn tool_defs_ask_user_choice_has_options_array() {
218        let dir = std::env::temp_dir();
219        let tools = make_tools(&dir);
220        let def = tools
221            .tool_defs()
222            .into_iter()
223            .find(|t| t.name == "ask_user_choice")
224            .unwrap();
225        let required = def.parameters["required"].as_array().unwrap();
226        assert!(required.iter().any(|v| v == "prompt"));
227        assert!(required.iter().any(|v| v == "options"));
228        assert_eq!(def.parameters["properties"]["options"]["type"], "array");
229    }
230
231    #[tokio::test]
232    async fn context_tools_return_runtime_error() {
233        let dir = std::env::temp_dir();
234        let tools = make_tools(&dir);
235        for name in [
236            "context_write",
237            "context_append",
238            "context_read",
239            "context_delete",
240            "context_list",
241        ] {
242            let result = tools.execute(name, serde_json::json!({})).await;
243            assert!(result.contains("context tools must be handled by the runtime"));
244        }
245    }
246
247    /// `submit_output` writes an ECS component and a context region, neither of
248    /// which the built-in executor can reach. Refused here so the runtime stays
249    /// the only path that can record an answer: a second path would let a
250    /// submission land somewhere no consumer reads.
251    #[tokio::test]
252    async fn submit_output_is_not_handled_by_builtin_execute() {
253        let dir = std::env::temp_dir();
254        let tools = make_tools(&dir);
255        let result = tools
256            .execute(
257                crate::SUBMIT_OUTPUT_TOOL,
258                serde_json::json!({"content": "the answer"}),
259            )
260            .await;
261        assert!(
262            result.contains("submit_output must be handled by the runtime"),
263            "{result}"
264        );
265    }
266
267    /// The description is the whole mechanism for arbitrary formats, so a stage
268    /// that declares nothing gets the generic wording rather than an invented
269    /// sentence about a format nobody asked for.
270    #[test]
271    fn the_submit_description_carries_a_declared_shape_and_nothing_otherwise() {
272        let generic = submit_output_description("");
273        assert!(generic.contains("artifacts"), "{generic}");
274        assert!(!generic.contains("a2ui"));
275
276        let shaped = submit_output_description("Return it in this format: a2ui.");
277        assert!(shaped.starts_with(&generic), "the generic part is kept");
278        assert!(shaped.ends_with("Return it in this format: a2ui."));
279    }
280
281    #[tokio::test]
282    async fn ask_user_tools_not_handled_by_builtin_execute() {
283        // ask_user_* tools are intercepted upstream (worker.rs/foreground.rs),
284        // exactly like present_for_review - execute() must never run them.
285        let dir = std::env::temp_dir();
286        let tools = make_tools(&dir);
287        for name in [
288            "ask_user_text",
289            "ask_user_choice",
290            "ask_user_confirm",
291            "edit_document",
292        ] {
293            let result = tools.execute(name, serde_json::json!({})).await;
294            assert!(result.contains("Unknown built-in tool"));
295        }
296    }
297
298    #[test]
299    fn context_tool_descriptions_mention_key_concepts() {
300        let dir = std::env::temp_dir();
301        let tools = make_tools(&dir);
302        let defs = tools.tool_defs();
303
304        let write_def = defs.iter().find(|t| t.name == "context_write").unwrap();
305        assert!(
306            write_def.description.contains("system prompt"),
307            "context_write should mention system prompt: {}",
308            write_def.description
309        );
310        assert!(
311            write_def.description.contains("replaced"),
312            "context_write should mention replacement: {}",
313            write_def.description
314        );
315
316        let read_def = defs.iter().find(|t| t.name == "context_read").unwrap();
317        assert!(
318            read_def.description.contains("summary"),
319            "context_read should mention summary: {}",
320            read_def.description
321        );
322
323        let list_def = defs.iter().find(|t| t.name == "context_list").unwrap();
324        assert!(
325            list_def.description.contains("token"),
326            "context_list should mention tokens: {}",
327            list_def.description
328        );
329
330        let append_def = defs.iter().find(|t| t.name == "context_append").unwrap();
331        assert!(
332            append_def.description.contains("without replacing"),
333            "context_append should mention 'without replacing': {}",
334            append_def.description
335        );
336    }
337
338    fn assert_has_description(name: &str, description: &str) {
339        assert!(
340            !description.is_empty(),
341            "tool {} has empty description",
342            name
343        );
344    }
345
346    fn assert_has_object_params(name: &str, params: &serde_json::Value) {
347        assert!(params.is_object(), "tool {} has non-object params", name);
348    }
349
350    #[test]
351    fn tool_defs_have_descriptions() {
352        let dir = std::env::temp_dir();
353        let tools = make_tools(&dir);
354        for def in tools.tool_defs() {
355            assert_has_description(&def.name, &def.description);
356        }
357    }
358
359    #[test]
360    #[should_panic(expected = "tool bogus has empty description")]
361    fn tool_defs_have_descriptions_panics_on_empty_description() {
362        assert_has_description("bogus", "");
363    }
364
365    #[test]
366    fn tool_defs_have_parameters() {
367        let dir = std::env::temp_dir();
368        let tools = make_tools(&dir);
369        for def in tools.tool_defs() {
370            assert_has_object_params(&def.name, &def.parameters);
371        }
372    }
373
374    #[test]
375    #[should_panic(expected = "tool bogus has non-object params")]
376    fn tool_defs_have_parameters_panics_on_non_object_params() {
377        assert_has_object_params("bogus", &serde_json::Value::Null);
378    }
379
380    // ── names() ───────────────────────────────────────────────────────────
381
382    #[test]
383    fn names_includes_bash_alias() {
384        let dir = std::env::temp_dir();
385        let tools = make_tools(&dir);
386        let names = tools.names();
387        assert!(names.contains(&"bash".to_string()));
388        assert!(names.contains(&"shell".to_string()));
389    }
390
391    /// Policy is matched against the name the model calls, which is always
392    /// canonical, while the writer of a config may have picked either spelling.
393    /// Both have to find each other, or a `bash` entry is dead.
394    #[test]
395    fn tool_name_spellings_covers_both_directions_without_repeating() {
396        fn of(n: &str) -> Vec<&str> {
397            tool_name_spellings(n).collect()
398        }
399        assert_eq!(of("shell"), ["shell", "bash"]);
400        assert_eq!(of("bash"), ["bash", "shell"]);
401        // A name with no alias yields itself once, not twice.
402        assert_eq!(of("read_file"), ["read_file"]);
403        assert_eq!(of("linear__search"), ["linear__search"]);
404    }
405
406    #[test]
407    fn canonical_tool_name_resolves_aliases_and_passes_others_through() {
408        // An alias resolves to its canonical name.
409        assert_eq!(canonical_tool_name("bash"), "shell");
410        // A canonical built-in is unchanged.
411        assert_eq!(canonical_tool_name("shell"), "shell");
412        assert_eq!(canonical_tool_name("read_file"), "read_file");
413        // An unknown name (e.g. an MCP tool whose server may not be installed)
414        // passes through untouched, so it is matched/omitted as-is.
415        assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
416        // Every alias in the table round-trips to a real canonical name.
417        for (alias, canonical) in TOOL_ALIASES {
418            assert_eq!(canonical_tool_name(alias), *canonical);
419        }
420    }
421
422    #[test]
423    fn names_returns_twenty_one_entries() {
424        let dir = std::env::temp_dir();
425        let tools = make_tools(&dir);
426        assert_eq!(tools.names().len(), 21);
427    }
428
429    // ── Sub-agent tool definitions ────────────────────────────────────────
430
431    #[test]
432    fn subagent_tool_defs_returns_five_tools() {
433        let defs = BuiltinTools::subagent_tool_defs();
434        assert_eq!(defs.len(), 5);
435    }
436
437    #[test]
438    fn subagent_tool_names_returns_five_names() {
439        let names = BuiltinTools::subagent_tool_names();
440        assert_eq!(names.len(), 5);
441        assert!(names.contains(&"spawn_agent".to_string()));
442        assert!(names.contains(&"check_agent".to_string()));
443        assert!(names.contains(&"wait_for_agent".to_string()));
444        assert!(names.contains(&"send_to_agent".to_string()));
445        assert!(names.contains(&"kill_agent".to_string()));
446    }
447
448    #[test]
449    fn subagent_tool_defs_names_match_subagent_tool_names() {
450        let defs = BuiltinTools::subagent_tool_defs();
451        let names = BuiltinTools::subagent_tool_names();
452        let def_names: Vec<String> = defs.iter().map(|d| d.name.clone()).collect();
453        assert_eq!(def_names, names);
454    }
455
456    // ── resolve() ─────────────────────────────────────────────────────────
457
458    #[test]
459    fn resolve_relative_path() {
460        let dir = std::env::temp_dir();
461        let tools = make_tools(&dir);
462        let result = tools.resolve("hello.txt").unwrap();
463        assert!(result.starts_with(&tools.ctx.workdir));
464        assert!(result.ends_with("hello.txt"));
465    }
466
467    #[test]
468    fn resolve_rejects_path_escape() {
469        let dir = std::env::temp_dir().join("leviath_test_sandbox");
470        fs::create_dir_all(&dir).ok();
471        let tools = make_tools(&dir);
472        let result = tools.resolve("../../etc/passwd");
473        assert!(result.is_err());
474    }
475
476    /// The escape a lexical check cannot see. `<workdir>/link -> /` makes
477    /// `link/etc/passwd` textually contained the whole way, and the old
478    /// `starts_with` containment let `fs::read_to_string` follow it straight out.
479    ///
480    /// This matters most where the containment is load-bearing: Leviath's file
481    /// tools run on the *host* over the bind-mounted workdir even when the
482    /// The containment refusal itself, driven through the injected predicate so
483    /// it is exercised on every platform. The `#[cfg(unix)]` tests below prove
484    /// the same refusal against a real symlink; this one proves the arm exists
485    /// and fires on Windows too, where a test cannot create one.
486    #[test]
487    fn resolve_refuses_a_path_that_does_not_resolve_within_the_workdir() {
488        fn escapes(_: &Path, _: &Path) -> bool {
489            false
490        }
491        let dir = tempfile::tempdir().unwrap();
492        let err = BuiltinTools::resolve_within("notes.txt", dir.path(), escapes)
493            .expect_err("a path that resolves outside must be refused");
494        assert!(err.to_string().contains("symlink"), "{err}");
495    }
496
497    /// The converse, so the test above is not passing merely because everything
498    /// is refused: with the real predicate an ordinary path resolves.
499    #[test]
500    fn resolve_admits_an_ordinary_path_within_the_workdir() {
501        let dir = tempfile::tempdir().unwrap();
502        let resolved =
503            BuiltinTools::resolve_within("notes.txt", dir.path(), leviath_core::resolves_within)
504                .expect("an ordinary path resolves");
505        assert!(resolved.ends_with("notes.txt"));
506    }
507
508    /// stage's `shell` is confined to a container, so a symlink the agent made
509    /// inside the container escaped the container through these tools. It is also
510    /// reachable from a checked-in symlink in a freshly cloned repository, which
511    /// is exactly what a coding agent is pointed at.
512    #[cfg(unix)]
513    #[tokio::test]
514    async fn resolve_rejects_symlink_escape() {
515        let dir = tempfile::tempdir().unwrap();
516        let workdir = dir.path().join("workspace");
517        fs::create_dir(&workdir).unwrap();
518        std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
519        let tools = make_tools(&workdir);
520
521        // Precondition: this is textually inside the workdir, so a lexical
522        // `starts_with` containment check alone would pass it.
523        // Built from `ctx.workdir` rather than `workdir` because the context
524        // canonicalizes (on macOS `/var` becomes `/private/var`).
525        let normalized = tools.ctx.workdir.join("link/etc/hosts");
526        assert!(normalized.starts_with(&tools.ctx.workdir));
527
528        let err = tools.resolve("link/etc/hosts").unwrap_err().to_string();
529        assert!(err.contains("symlink"), "got: {err}");
530
531        // And the tool itself refuses rather than returning the file.
532        let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
533        assert!(out.contains("[error]"), "got: {out}");
534    }
535
536    /// A write through an escaping symlink is refused too - this was the path
537    /// that could overwrite `~/.ssh/authorized_keys`.
538    #[cfg(unix)]
539    #[tokio::test]
540    async fn write_file_rejects_symlink_escape() {
541        let dir = tempfile::tempdir().unwrap();
542        let outside = tempfile::tempdir().unwrap();
543        let workdir = dir.path().join("workspace");
544        fs::create_dir(&workdir).unwrap();
545        std::os::unix::fs::symlink(outside.path(), workdir.join("link")).unwrap();
546        let tools = make_tools(&workdir);
547
548        let out = tools
549            .write_file(&json!({ "path": "link/pwned.txt", "content": "x" }))
550            .await;
551        assert!(out.contains("[error]"), "got: {out}");
552        assert!(
553            !outside.path().join("pwned.txt").exists(),
554            "nothing may be written outside the workdir"
555        );
556    }
557
558    // ── [read_paths]: reads may be granted outside the workdir ────────────
559
560    /// Tools whose context carries a `[read_paths]` policy compiled for
561    /// `workdir` (no home, unix path semantics - the platform seams have
562    /// their own tests in `leviath_core::read_paths`).
563    fn make_tools_with_read_paths(
564        workdir: &std::path::Path,
565        blueprint: &[&str],
566        grants: &[&str],
567        allow_blueprint: bool,
568    ) -> BuiltinTools {
569        let compile = |entries: &[&str]| {
570            let raw: Vec<String> = entries.iter().map(|s| s.to_string()).collect();
571            leviath_core::ReadPathSet::compile(&raw, workdir, None, false)
572                .expect("test entries compile")
573        };
574        let policy = leviath_core::ReadPathPolicy {
575            agent: "tester".into(),
576            blueprint: compile(blueprint),
577            grants: compile(grants),
578            allow_blueprint,
579        };
580        BuiltinTools::new(ToolContext::new(workdir.to_path_buf()).with_read_paths(policy))
581    }
582
583    /// The whole point of the feature: a declared-and-granted directory is
584    /// readable, through every read-only tool.
585    #[tokio::test]
586    async fn read_tools_reach_a_declared_and_granted_outside_path() {
587        let dir = tempfile::tempdir().unwrap();
588        let outside = tempfile::tempdir().unwrap();
589        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
590        let entry = outside.path().to_str().unwrap();
591        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
592
593        let target = outside.path().join("doc.md");
594        let target = target.to_str().unwrap();
595        let out = tools.read_file(&json!({ "path": target })).await;
596        assert_eq!(out, "outside contents");
597
598        let listed = tools
599            .list_dir(&json!({ "path": outside.path().to_str().unwrap() }))
600            .await;
601        assert!(listed.contains("doc.md"), "got: {listed}");
602
603        // `read_files` mixes inside and outside paths per element.
604        fs::write(dir.path().join("inside.txt"), "inside contents").unwrap();
605        let out = tools
606            .read_files(&json!({ "paths": ["inside.txt", target] }))
607            .await;
608        assert!(out.contains("inside contents"), "got: {out}");
609        assert!(out.contains("outside contents"), "got: {out}");
610    }
611
612    /// `[read_paths]` grants reads and nothing else: the same fully granted
613    /// path is still refused for `write_file` and `edit_file`, which never
614    /// consult the policy.
615    #[tokio::test]
616    async fn write_and_edit_stay_confined_despite_read_grants() {
617        let dir = tempfile::tempdir().unwrap();
618        let outside = tempfile::tempdir().unwrap();
619        fs::write(outside.path().join("doc.md"), "original").unwrap();
620        let entry = outside.path().to_str().unwrap();
621        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
622
623        let target = outside.path().join("doc.md");
624        let target = target.to_str().unwrap();
625        let out = tools
626            .write_file(&json!({ "path": target, "content": "clobbered" }))
627            .await;
628        assert!(out.contains("[error]"), "got: {out}");
629        let out = tools
630            .edit_file(&json!({ "path": target, "old_str": "original", "new_str": "x" }))
631            .await;
632        assert!(out.contains("[error]"), "got: {out}");
633        assert_eq!(
634            fs::read_to_string(outside.path().join("doc.md")).unwrap(),
635            "original",
636            "a read grant must never permit a write"
637        );
638    }
639
640    /// Declared by the blueprint but granted by nothing: refused, and the
641    /// error says exactly which config stanza would grant it.
642    #[tokio::test]
643    async fn an_ungranted_declaration_is_refused_with_guidance() {
644        let dir = tempfile::tempdir().unwrap();
645        let outside = tempfile::tempdir().unwrap();
646        fs::write(outside.path().join("doc.md"), "secret").unwrap();
647        let entry = outside.path().to_str().unwrap();
648        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], false);
649
650        let target = outside.path().join("doc.md");
651        let out = tools
652            .read_file(&json!({ "path": target.to_str().unwrap() }))
653            .await;
654        assert!(out.contains("[error]"), "got: {out}");
655        assert!(out.contains("does not grant"), "got: {out}");
656        assert!(out.contains("[agent_read_paths.tester]"), "got: {out}");
657        assert!(!out.contains("secret"), "content must not leak");
658    }
659
660    /// The `allow_blueprint_read_paths` override honors declarations without
661    /// itemized grants - and still nothing beyond what is declared.
662    #[tokio::test]
663    async fn the_blanket_override_honors_declarations() {
664        let dir = tempfile::tempdir().unwrap();
665        let outside = tempfile::tempdir().unwrap();
666        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
667        let entry = outside.path().to_str().unwrap();
668        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], true);
669
670        let target = outside.path().join("doc.md");
671        let out = tools
672            .read_file(&json!({ "path": target.to_str().unwrap() }))
673            .await;
674        assert_eq!(out, "outside contents");
675
676        // Undeclared stays undeclared: the override widens nothing.
677        let undeclared = tempfile::tempdir().unwrap();
678        fs::write(undeclared.path().join("x.txt"), "x").unwrap();
679        let out = tools
680            .read_file(&json!({ "path": undeclared.path().join("x.txt").to_str().unwrap() }))
681            .await;
682        assert!(
683            out.contains("not in this agent's [read_paths]"),
684            "got: {out}"
685        );
686    }
687
688    /// With no `[read_paths]` at all, an outside read gets the original
689    /// workdir refusal, word for word - the policy is never consulted.
690    #[tokio::test]
691    async fn an_inactive_policy_keeps_the_workdir_error() {
692        let dir = tempfile::tempdir().unwrap();
693        let tools = make_tools(dir.path());
694        let out = tools.read_file(&json!({ "path": "/etc/hosts" })).await;
695        assert!(
696            out.contains("would escape the working directory"),
697            "got: {out}"
698        );
699    }
700
701    /// A relative request resolves against the workdir in the fallback too,
702    /// so a workdir-relative entry like `../shared` is reachable by the
703    /// matching relative request.
704    #[tokio::test]
705    async fn a_relative_request_reaches_a_relative_grant() {
706        let parent = tempfile::tempdir().unwrap();
707        let workdir = parent.path().join("work");
708        let shared = parent.path().join("shared");
709        fs::create_dir_all(&workdir).unwrap();
710        fs::create_dir_all(&shared).unwrap();
711        fs::write(shared.join("doc.md"), "shared contents").unwrap();
712        let tools = make_tools_with_read_paths(&workdir, &["../shared"], &["../shared"], false);
713
714        let out = tools
715            .read_file(&json!({ "path": "../shared/doc.md" }))
716            .await;
717        assert_eq!(out, "shared contents");
718    }
719
720    /// An interior `.` in a fallback request is folded away (`Path::components`
721    /// drops it), so `<granted>/./doc.md` resolves the same as
722    /// `<granted>/doc.md`.
723    #[tokio::test]
724    async fn a_dot_component_is_folded_in_the_fallback() {
725        let dir = tempfile::tempdir().unwrap();
726        let outside = tempfile::tempdir().unwrap();
727        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
728        let entry = outside.path().to_str().unwrap();
729        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
730
731        let target = format!("{}/./doc.md", outside.path().to_str().unwrap());
732        let out = tools.read_file(&json!({ "path": target })).await;
733        assert_eq!(out, "outside contents");
734    }
735
736    /// Folding `..` past the top is unresolvable no matter what any allowlist
737    /// says. Mirrors `resolve_rejects_excessive_parent_dir_traversal`: a
738    /// *relative* base (`wd`) gives the accumulator exactly one leading
739    /// `Normal` component and no platform-specific root/drive/prefix, so the
740    /// first `..` pops `wd` and the second calls `pop()` on an empty
741    /// accumulator - firing the bail on every OS. `/..` or an empty base does
742    /// not: neither is absolute on Windows, and the join reshapes them so the
743    /// `pop()` never fails there.
744    #[test]
745    fn folding_past_the_root_is_unresolvable() {
746        let policy = leviath_core::ReadPathPolicy {
747            agent: "tester".into(),
748            allow_blueprint: true,
749            ..Default::default()
750        };
751        let err = BuiltinTools::resolve_outside(
752            "../../x",
753            Path::new("wd"),
754            &policy,
755            leviath_core::canonicalize_for_match,
756        )
757        .expect_err("popping past the top must be refused");
758        assert!(err.to_string().contains("cannot be resolved"), "{err}");
759    }
760
761    /// The fail-closed arm, driven through the injected canonicalizer so it
762    /// runs on every platform: a path nothing can verify is refused, never
763    /// matched.
764    #[test]
765    fn an_unverifiable_path_is_refused() {
766        fn unverifiable(_: &Path) -> Option<PathBuf> {
767            None
768        }
769        let policy = leviath_core::ReadPathPolicy {
770            agent: "tester".into(),
771            allow_blueprint: true,
772            ..Default::default()
773        };
774        let err =
775            BuiltinTools::resolve_outside("/outside/x", Path::new("/w"), &policy, unverifiable)
776                .expect_err("an unverifiable path must be refused");
777        assert!(err.to_string().contains("cannot be verified"), "{err}");
778    }
779
780    /// The attack the policy exists to stop: a symlink planted *inside* a
781    /// granted directory, pointing outside it. The policy sees the real
782    /// target, which no entry declares.
783    #[cfg(unix)]
784    #[tokio::test]
785    async fn a_symlink_inside_a_granted_directory_cannot_escape_it() {
786        let dir = tempfile::tempdir().unwrap();
787        let granted = tempfile::tempdir().unwrap();
788        let secret_home = tempfile::tempdir().unwrap();
789        fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
790        std::os::unix::fs::symlink(
791            secret_home.path().join("id_rsa"),
792            granted.path().join("innocent.md"),
793        )
794        .unwrap();
795        let entry = granted.path().to_str().unwrap();
796        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
797
798        let out = tools
799            .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
800            .await;
801        assert!(out.contains("[error]"), "got: {out}");
802        assert!(!out.contains("PRIVATE KEY"), "content must not leak");
803    }
804
805    /// The same attack against a glob entry - the variant the original PR
806    /// missed entirely. The pattern is matched against the symlink-resolved
807    /// real path, and the real target does not match it.
808    #[cfg(unix)]
809    #[tokio::test]
810    async fn a_glob_grant_is_symlink_safe() {
811        let dir = tempfile::tempdir().unwrap();
812        let granted = tempfile::tempdir().unwrap();
813        let secret_home = tempfile::tempdir().unwrap();
814        fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
815        std::os::unix::fs::symlink(
816            secret_home.path().join("id_rsa"),
817            granted.path().join("innocent.md"),
818        )
819        .unwrap();
820        // Patterns match the canonical real path, so build the entry from it.
821        let canonical = fs::canonicalize(granted.path()).unwrap();
822        let entry = format!("glob:{}/**", canonical.display());
823        let tools = make_tools_with_read_paths(dir.path(), &[&entry], &[&entry], false);
824
825        let out = tools
826            .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
827            .await;
828        assert!(out.contains("[error]"), "got: {out}");
829        assert!(!out.contains("PRIVATE KEY"), "content must not leak");
830
831        // The positive pair: a real file under the same glob is readable, so
832        // the refusal above is the symlink and not the pattern.
833        fs::write(granted.path().join("real.md"), "real contents").unwrap();
834        let out = tools
835            .read_file(&json!({ "path": granted.path().join("real.md").to_str().unwrap() }))
836            .await;
837        assert_eq!(out, "real contents");
838    }
839
840    /// A symlink whose target stays inside the granted subtree is fine - the
841    /// rule is about where the path lands, exactly as in the workdir.
842    #[cfg(unix)]
843    #[tokio::test]
844    async fn a_symlink_within_a_granted_directory_is_readable() {
845        let dir = tempfile::tempdir().unwrap();
846        let granted = tempfile::tempdir().unwrap();
847        fs::create_dir(granted.path().join("real")).unwrap();
848        fs::write(granted.path().join("real/doc.md"), "granted contents").unwrap();
849        std::os::unix::fs::symlink(granted.path().join("real"), granted.path().join("link"))
850            .unwrap();
851        let entry = granted.path().to_str().unwrap();
852        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
853
854        let out = tools
855            .read_file(&json!({ "path": granted.path().join("link/doc.md").to_str().unwrap() }))
856            .await;
857        assert_eq!(out, "granted contents");
858    }
859
860    /// A symlink that stays *inside* the workdir keeps working - the rule is
861    /// about where the path lands, not whether a symlink was involved. Agents
862    /// operate on real repositories, which contain plenty of internal symlinks.
863    #[cfg(unix)]
864    #[tokio::test]
865    async fn resolve_allows_symlink_within_workdir() {
866        let dir = tempfile::tempdir().unwrap();
867        let workdir = dir.path().join("workspace");
868        fs::create_dir(&workdir).unwrap();
869        fs::create_dir(workdir.join("real")).unwrap();
870        fs::write(workdir.join("real/file.txt"), "contents").unwrap();
871        std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")).unwrap();
872        let tools = make_tools(&workdir);
873
874        assert!(tools.resolve("link/file.txt").is_ok());
875        let out = tools.read_file(&json!({ "path": "link/file.txt" })).await;
876        assert_eq!(out, "contents");
877    }
878
879    #[test]
880    fn resolve_dot_stays_in_workdir() {
881        let dir = std::env::temp_dir();
882        let tools = make_tools(&dir);
883        let result = tools.resolve("./foo/./bar.txt").unwrap();
884        assert!(result.starts_with(&tools.ctx.workdir));
885        assert!(result.ends_with("foo/bar.txt"));
886    }
887
888    // ── execute() with file I/O (async) ───────────────────────────────────
889
890    #[tokio::test]
891    async fn execute_unknown_tool_returns_error() {
892        let dir = std::env::temp_dir();
893        let tools = make_tools(&dir);
894        let result = tools.execute("nonexistent", json!({})).await;
895        assert!(result.contains("[error]"));
896        assert!(result.contains("Unknown built-in tool"));
897    }
898
899    #[tokio::test]
900    async fn read_file_missing_path_arg() {
901        let dir = std::env::temp_dir();
902        let tools = make_tools(&dir);
903        let result = tools.execute("read_file", json!({})).await;
904        assert!(result.contains("[error]"));
905        assert!(result.contains("missing 'path'"));
906    }
907
908    #[tokio::test]
909    async fn write_and_read_file_roundtrip() {
910        let dir = tempfile::tempdir().unwrap();
911        let tools = make_tools(dir.path());
912
913        let write_result = tools
914            .execute(
915                "write_file",
916                json!({"path": "test.txt", "content": "hello world"}),
917            )
918            .await;
919        assert!(write_result.contains("Successfully wrote"));
920        assert!(write_result.contains("11 bytes"));
921
922        let read_result = tools
923            .execute("read_file", json!({"path": "test.txt"}))
924            .await;
925        assert_eq!(read_result, "hello world");
926    }
927
928    #[tokio::test]
929    async fn write_file_creates_parent_dirs() {
930        let dir = tempfile::tempdir().unwrap();
931        let tools = make_tools(dir.path());
932
933        let result = tools
934            .execute(
935                "write_file",
936                json!({"path": "sub/dir/file.txt", "content": "nested"}),
937            )
938            .await;
939        assert!(result.contains("Successfully wrote"));
940        assert!(dir.path().join("sub/dir/file.txt").exists());
941    }
942
943    #[tokio::test]
944    async fn write_tools_refuse_to_resurrect_a_deleted_workspace() {
945        // Issue #107: an external harness deletes the workspace mid-run.
946        // `create_dir_all` would happily recreate it and let the agent write
947        // into an empty tree that no longer resembles the checkout it reasoned
948        // about - and the runtime's health check, which just stats the workdir,
949        // would never see it was gone.
950        let dir = tempfile::tempdir().unwrap();
951        let workdir = dir.path().join("workspace");
952        fs::create_dir(&workdir).unwrap();
953        fs::write(workdir.join("a.txt"), "before").unwrap();
954        let tools = make_tools(&workdir);
955        fs::remove_dir_all(&workdir).unwrap();
956
957        for (tool, args) in [
958            ("write_file", json!({"path": "a.txt", "content": "after"})),
959            (
960                "edit_file",
961                json!({"path": "a.txt", "old_str": "before", "new_str": "after"}),
962            ),
963        ] {
964            let result = tools.execute(tool, args).await;
965            assert!(
966                result.contains("workspace") && result.contains("no longer accessible"),
967                "{tool} got: {result}"
968            );
969        }
970        assert!(!workdir.exists(), "the workspace must stay gone");
971    }
972
973    #[tokio::test]
974    async fn write_file_missing_content_arg() {
975        let dir = tempfile::tempdir().unwrap();
976        let tools = make_tools(dir.path());
977        let result = tools.execute("write_file", json!({"path": "f.txt"})).await;
978        assert!(result.contains("missing 'content'"));
979    }
980
981    #[tokio::test]
982    async fn write_file_missing_path_arg() {
983        let dir = tempfile::tempdir().unwrap();
984        let tools = make_tools(dir.path());
985        let result = tools.execute("write_file", json!({"content": "x"})).await;
986        assert!(result.contains("missing 'path'"));
987    }
988
989    #[test]
990    fn resolve_rejects_excessive_parent_dir_traversal() {
991        // A *relative, nonexistent* workdir keeps `resolve`'s accumulator free
992        // of any platform-specific leading root/drive/prefix components:
993        // `canonicalize` fails for a path that doesn't exist (on every OS), so
994        // `ToolContext::new` keeps the raw relative `PathBuf` verbatim. The
995        // request then decomposes into exactly `[Normal(workdir), ParentDir,
996        // ParentDir, ...]`; the first `..` pops the single workdir component and
997        // the second `..` calls `normalized.pop()` on an *empty* accumulator,
998        // which returns `false` - firing the "escapes the working directory"
999        // bail deterministically on every OS.
1000        //
1001        // (An empty "" workdir is not portable here: on Windows `canonicalize("")`
1002        // can succeed and yield an absolute cwd whose Prefix/RootDir components
1003        // absorb the `..`, so `pop()` never fails and this bail is never hit --
1004        // which is exactly why this branch was Windows-uncovered before.)
1005        let tools = BuiltinTools::new(ToolContext::new(PathBuf::from(
1006            "leviath-nonexistent-relative-workdir",
1007        )));
1008        let result = tools.resolve("../../etc/passwd");
1009        assert!(result.is_err());
1010        assert!(
1011            result
1012                .unwrap_err()
1013                .to_string()
1014                .contains("escapes the working directory")
1015        );
1016    }
1017
1018    #[tokio::test]
1019    async fn edit_file_successful_replacement() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let tools = make_tools(dir.path());
1022
1023        tools
1024            .execute(
1025                "write_file",
1026                json!({"path": "e.txt", "content": "foo bar baz"}),
1027            )
1028            .await;
1029
1030        let result = tools
1031            .execute(
1032                "edit_file",
1033                json!({"path": "e.txt", "old_str": "bar", "new_str": "qux"}),
1034            )
1035            .await;
1036        assert!(result.contains("Successfully edited"));
1037
1038        let content = tools.execute("read_file", json!({"path": "e.txt"})).await;
1039        assert_eq!(content, "foo qux baz");
1040    }
1041
1042    #[tokio::test]
1043    async fn edit_file_string_not_found() {
1044        let dir = tempfile::tempdir().unwrap();
1045        let tools = make_tools(dir.path());
1046
1047        tools
1048            .execute("write_file", json!({"path": "e.txt", "content": "abc"}))
1049            .await;
1050
1051        let result = tools
1052            .execute(
1053                "edit_file",
1054                json!({"path": "e.txt", "old_str": "xyz", "new_str": "123"}),
1055            )
1056            .await;
1057        assert!(result.contains("String not found"));
1058    }
1059
1060    #[tokio::test]
1061    async fn edit_file_missing_file_returns_read_error() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let tools = make_tools(dir.path());
1064
1065        let result = tools
1066            .execute(
1067                "edit_file",
1068                json!({"path": "does-not-exist.txt", "old_str": "a", "new_str": "b"}),
1069            )
1070            .await;
1071        assert!(result.contains("[error]"));
1072        assert!(result.contains("Failed to read"));
1073    }
1074
1075    #[tokio::test]
1076    async fn edit_file_multiple_occurrences() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let tools = make_tools(dir.path());
1079
1080        tools
1081            .execute("write_file", json!({"path": "e.txt", "content": "aaa aaa"}))
1082            .await;
1083
1084        let result = tools
1085            .execute(
1086                "edit_file",
1087                json!({"path": "e.txt", "old_str": "aaa", "new_str": "bbb"}),
1088            )
1089            .await;
1090        assert!(result.contains("2 occurrences"));
1091        assert!(result.contains("must be unique"));
1092    }
1093
1094    #[tokio::test]
1095    async fn edit_file_missing_args() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let tools = make_tools(dir.path());
1098
1099        let r1 = tools.execute("edit_file", json!({})).await;
1100        assert!(r1.contains("missing 'path'"));
1101
1102        let r2 = tools.execute("edit_file", json!({"path": "f.txt"})).await;
1103        assert!(r2.contains("missing 'old_str'"));
1104
1105        let r3 = tools
1106            .execute("edit_file", json!({"path": "f.txt", "old_str": "x"}))
1107            .await;
1108        assert!(r3.contains("missing 'new_str'"));
1109    }
1110
1111    #[tokio::test]
1112    async fn list_dir_contents() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let tools = make_tools(dir.path());
1115
1116        fs::write(dir.path().join("a.txt"), "hello").unwrap();
1117        fs::create_dir(dir.path().join("subdir")).unwrap();
1118
1119        let result = tools.execute("list_dir", json!({})).await;
1120        assert!(result.contains("a.txt"));
1121        assert!(result.contains("subdir/"));
1122    }
1123
1124    #[tokio::test]
1125    async fn list_dir_empty() {
1126        let dir = tempfile::tempdir().unwrap();
1127        let tools = make_tools(dir.path());
1128        let result = tools.execute("list_dir", json!({})).await;
1129        assert!(result.contains("empty directory"));
1130    }
1131
1132    #[tokio::test]
1133    async fn list_dir_with_path() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let tools = make_tools(dir.path());
1136
1137        fs::create_dir(dir.path().join("sub")).unwrap();
1138        fs::write(dir.path().join("sub/inner.txt"), "data").unwrap();
1139
1140        let result = tools.execute("list_dir", json!({"path": "sub"})).await;
1141        assert!(result.contains("inner.txt"));
1142    }
1143
1144    #[tokio::test]
1145    async fn read_file_nonexistent() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let tools = make_tools(dir.path());
1148        let result = tools
1149            .execute("read_file", json!({"path": "nope.txt"}))
1150            .await;
1151        assert!(result.contains("[error]"));
1152        assert!(result.contains("Failed to read"));
1153    }
1154
1155    // ── read_files (batch reads) ────────────────────────────────────────────
1156
1157    #[tokio::test]
1158    async fn read_files_multiple_valid_files() {
1159        let dir = tempfile::tempdir().unwrap();
1160        let tools = make_tools(dir.path());
1161        fs::write(dir.path().join("a.txt"), "alpha").unwrap();
1162        fs::write(dir.path().join("b.txt"), "beta").unwrap();
1163
1164        let result = tools
1165            .execute("read_files", json!({"paths": ["a.txt", "b.txt"]}))
1166            .await;
1167        assert!(result.contains("### [a.txt]"));
1168        assert!(result.contains("alpha"));
1169        assert!(result.contains("### [b.txt]"));
1170        assert!(result.contains("beta"));
1171        // Results are joined with a blank line between entries.
1172        assert!(result.contains("\n\n"));
1173    }
1174
1175    #[tokio::test]
1176    async fn read_files_missing_paths_arg() {
1177        let dir = tempfile::tempdir().unwrap();
1178        let tools = make_tools(dir.path());
1179        let result = tools.execute("read_files", json!({})).await;
1180        assert!(result.contains("[error]"));
1181        assert!(result.contains("missing 'paths'"));
1182    }
1183
1184    #[tokio::test]
1185    async fn read_files_non_array_paths_arg() {
1186        let dir = tempfile::tempdir().unwrap();
1187        let tools = make_tools(dir.path());
1188        // A string (not an array) → as_array() returns None → same error path.
1189        let result = tools.execute("read_files", json!({"paths": "a.txt"})).await;
1190        assert!(result.contains("[error]"));
1191        assert!(result.contains("missing 'paths'"));
1192    }
1193
1194    #[tokio::test]
1195    async fn read_files_empty_paths_array() {
1196        let dir = tempfile::tempdir().unwrap();
1197        let tools = make_tools(dir.path());
1198        let result = tools.execute("read_files", json!({"paths": []})).await;
1199        assert!(result.contains("[error]"));
1200        assert!(result.contains("empty"));
1201    }
1202
1203    #[tokio::test]
1204    async fn read_files_missing_file_reports_per_file_error() {
1205        let dir = tempfile::tempdir().unwrap();
1206        let tools = make_tools(dir.path());
1207        fs::write(dir.path().join("present.txt"), "here").unwrap();
1208
1209        let result = tools
1210            .execute(
1211                "read_files",
1212                json!({"paths": ["present.txt", "absent.txt"]}),
1213            )
1214            .await;
1215        // Valid file still returned…
1216        assert!(result.contains("### [present.txt]"));
1217        assert!(result.contains("here"));
1218        // …while the missing one produces a per-file error under its header.
1219        assert!(result.contains("### [absent.txt]"));
1220        assert!(result.contains("Failed to read"));
1221    }
1222
1223    #[tokio::test]
1224    async fn read_files_non_string_element_reports_error() {
1225        let dir = tempfile::tempdir().unwrap();
1226        let tools = make_tools(dir.path());
1227        fs::write(dir.path().join("ok.txt"), "content").unwrap();
1228
1229        let result = tools
1230            .execute("read_files", json!({"paths": ["ok.txt", 42]}))
1231            .await;
1232        assert!(result.contains("content"));
1233        assert!(result.contains("non-string path in array"));
1234    }
1235
1236    #[tokio::test]
1237    async fn read_files_path_escape_reported_per_file() {
1238        let dir = tempfile::tempdir().unwrap();
1239        let tools = make_tools(dir.path());
1240        let result = tools
1241            .execute("read_files", json!({"paths": ["../../etc/passwd"]}))
1242            .await;
1243        assert!(result.contains("### [../../etc/passwd]"));
1244        assert!(result.contains("escape"));
1245    }
1246
1247    // ── resolve() absolute paths ────────────────────────────────────────────
1248
1249    #[test]
1250    fn resolve_absolute_path_inside_workdir() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let tools = make_tools(dir.path());
1253        // Build the absolute path from the tool's own (canonicalized) workdir
1254        // rather than `dir.path()` directly - on macOS `/tmp`/`/var` are
1255        // symlinks, so the two can differ even though they're the same place.
1256        let abs = tools.ctx.workdir.join("inside.txt");
1257        let result = tools.resolve(abs.to_str().unwrap()).unwrap();
1258        assert_eq!(result, abs);
1259    }
1260
1261    #[test]
1262    fn resolve_rejects_absolute_path_outside_workdir() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let tools = make_tools(dir.path());
1265        let result = tools.resolve("/etc/passwd");
1266        assert!(result.is_err());
1267    }
1268
1269    // ── path-escape rejection propagates through each tool ─────────────────
1270
1271    #[tokio::test]
1272    async fn read_file_path_escape_rejected() {
1273        let dir = tempfile::tempdir().unwrap();
1274        let tools = make_tools(dir.path());
1275        let result = tools
1276            .execute("read_file", json!({"path": "../../etc/passwd"}))
1277            .await;
1278        assert!(result.contains("[error]"));
1279        assert!(result.contains("escape"));
1280    }
1281
1282    #[tokio::test]
1283    async fn write_file_path_escape_rejected() {
1284        let dir = tempfile::tempdir().unwrap();
1285        let tools = make_tools(dir.path());
1286        let result = tools
1287            .execute(
1288                "write_file",
1289                json!({"path": "../../evil.txt", "content": "x"}),
1290            )
1291            .await;
1292        assert!(result.contains("[error]"));
1293        assert!(result.contains("escape"));
1294    }
1295
1296    #[tokio::test]
1297    async fn edit_file_path_escape_rejected() {
1298        let dir = tempfile::tempdir().unwrap();
1299        let tools = make_tools(dir.path());
1300        let result = tools
1301            .execute(
1302                "edit_file",
1303                json!({"path": "../../evil.txt", "old_str": "a", "new_str": "b"}),
1304            )
1305            .await;
1306        assert!(result.contains("[error]"));
1307        assert!(result.contains("escape"));
1308    }
1309
1310    #[tokio::test]
1311    async fn list_dir_path_escape_rejected() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let tools = make_tools(dir.path());
1314        let result = tools.execute("list_dir", json!({"path": "../../"})).await;
1315        assert!(result.contains("[error]"));
1316        assert!(result.contains("escape"));
1317    }
1318
1319    // ── filesystem failure branches ─────────────────────────────────────────
1320
1321    #[tokio::test]
1322    async fn write_file_fails_when_path_is_a_directory() {
1323        let dir = tempfile::tempdir().unwrap();
1324        let tools = make_tools(dir.path());
1325        fs::create_dir(dir.path().join("adir")).unwrap();
1326
1327        let result = tools
1328            .execute("write_file", json!({"path": "adir", "content": "x"}))
1329            .await;
1330        assert!(result.contains("[error]"));
1331        assert!(result.contains("Failed to write"));
1332    }
1333
1334    #[tokio::test]
1335    async fn write_file_parent_dir_creation_fails_when_blocked_by_file() {
1336        let dir = tempfile::tempdir().unwrap();
1337        let tools = make_tools(dir.path());
1338        // "blocker" exists as a plain file, so create_dir_all("blocker") must fail.
1339        fs::write(dir.path().join("blocker"), "im a file").unwrap();
1340
1341        let result = tools
1342            .execute(
1343                "write_file",
1344                json!({"path": "blocker/nested.txt", "content": "x"}),
1345            )
1346            .await;
1347        assert!(result.contains("[error]"));
1348        assert!(result.contains("Failed to create directories"));
1349    }
1350
1351    #[tokio::test]
1352    async fn read_file_fails_when_path_is_a_directory() {
1353        let dir = tempfile::tempdir().unwrap();
1354        let tools = make_tools(dir.path());
1355        fs::create_dir(dir.path().join("adir")).unwrap();
1356
1357        let result = tools.execute("read_file", json!({"path": "adir"})).await;
1358        assert!(result.contains("[error]"));
1359        assert!(result.contains("Failed to read"));
1360    }
1361
1362    #[tokio::test]
1363    async fn list_dir_fails_when_path_is_a_file() {
1364        let dir = tempfile::tempdir().unwrap();
1365        let tools = make_tools(dir.path());
1366        fs::write(dir.path().join("afile.txt"), "content").unwrap();
1367
1368        let result = tools
1369            .execute("list_dir", json!({"path": "afile.txt"}))
1370            .await;
1371        assert!(result.contains("[error]"));
1372        assert!(result.contains("Failed to read directory"));
1373    }
1374
1375    #[tokio::test]
1376    async fn edit_file_write_failure_after_successful_match() {
1377        let dir = tempfile::tempdir().unwrap();
1378        let tools = make_tools(dir.path());
1379        let file_path = dir.path().join("ro.txt");
1380        fs::write(&file_path, "hello world").unwrap();
1381
1382        // Make the file read-only so the read succeeds but the write-back
1383        // fails. `set_readonly(true)` is cross-platform (clears the write bits
1384        // on Unix; sets the read-only attribute on Windows), so the write
1385        // error arm is exercised on every OS. The original permissions are kept
1386        // so they can be put back exactly, rather than reconstructed.
1387        let original = fs::metadata(&file_path).unwrap().permissions();
1388        let mut perms = original.clone();
1389        perms.set_readonly(true);
1390        fs::set_permissions(&file_path, perms).unwrap();
1391
1392        let result = tools
1393            .execute(
1394                "edit_file",
1395                json!({"path": "ro.txt", "old_str": "hello", "new_str": "goodbye"}),
1396            )
1397            .await;
1398
1399        // Put the original permissions back so tempdir cleanup can remove the
1400        // file on Windows, where a read-only file cannot be deleted. Restoring
1401        // what was there beats `set_readonly(false)`, which on Unix sets *every*
1402        // write bit and would hand back 0o666 for a file that was 0o644.
1403        fs::set_permissions(&file_path, original).unwrap();
1404
1405        assert!(result.contains("[error]"));
1406        assert!(result.contains("Failed to write"));
1407    }
1408
1409    #[tokio::test]
1410    async fn shell_echo_command() {
1411        let dir = tempfile::tempdir().unwrap();
1412        let tools = make_tools(dir.path());
1413        let result = tools
1414            .execute("shell", json!({"command": "echo hello"}))
1415            .await;
1416        assert!(result.trim().contains("hello"));
1417    }
1418
1419    #[tokio::test]
1420    async fn bash_alias_works() {
1421        let dir = tempfile::tempdir().unwrap();
1422        let tools = make_tools(dir.path());
1423        let result = tools
1424            .execute("bash", json!({"command": "echo alias_test"}))
1425            .await;
1426        assert!(result.contains("alias_test"));
1427    }
1428
1429    #[tokio::test]
1430    async fn shell_missing_command_arg() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let tools = make_tools(dir.path());
1433        let result = tools.execute("shell", json!({})).await;
1434        assert!(result.contains("missing 'command'"));
1435    }
1436
1437    /// A `ShellExecutor` that ignores the requested command and instead runs a
1438    /// fixed marker command - proof that shell execution is routed through it.
1439    struct RedirectExecutor;
1440    impl ShellExecutor for RedirectExecutor {
1441        fn build_command(
1442            &self,
1443            shell: &str,
1444            flag: &str,
1445            _command: &str,
1446            workdir: &Path,
1447        ) -> Command {
1448            let mut c = Command::new(shell);
1449            c.arg(flag).arg("echo SANDBOXED").current_dir(workdir);
1450            c
1451        }
1452    }
1453
1454    #[tokio::test]
1455    async fn shell_routes_through_executor_when_present() {
1456        let dir = tempfile::tempdir().unwrap();
1457        let tools = BuiltinTools::new(ToolContext::new(dir.path().to_path_buf()))
1458            .with_shell_executor(Arc::new(RedirectExecutor));
1459        // The agent asked for `echo host`, but the executor redirects it.
1460        let result = tools
1461            .execute("shell", json!({"command": "echo host"}))
1462            .await;
1463        assert!(result.contains("SANDBOXED"), "got: {result}");
1464        assert!(!result.contains("host"));
1465    }
1466
1467    #[tokio::test]
1468    async fn shell_failing_command() {
1469        let dir = tempfile::tempdir().unwrap();
1470        let tools = make_tools(dir.path());
1471        let result = tools.execute("shell", json!({"command": "false"})).await;
1472        assert!(result.contains("[exit code"));
1473    }
1474
1475    #[tokio::test]
1476    async fn shell_successful_command_with_no_output() {
1477        let dir = tempfile::tempdir().unwrap();
1478        let tools = make_tools(dir.path());
1479        let result = tools.execute("shell", json!({"command": "true"})).await;
1480        assert_eq!(result, "(command succeeded with no output)");
1481    }
1482
1483    // The stdout+stderr non-zero-exit formatting is asserted directly against
1484    // `format_command_output` (below) rather than via a real shell command:
1485    // producing stdout, stderr, and a non-zero exit in a single command needs
1486    // shell-specific syntax (`;`/`1>&2` on `sh`, `&`/redirection on `cmd.exe`)
1487    // that isn't portable, and this session already hit real Windows CI
1488    // failures from insufficiently-verified platform-specific test commands.
1489    #[test]
1490    fn format_command_output_non_zero_exit_reports_stdout_and_stderr() {
1491        let result = BuiltinTools::format_command_output(b"out-line\n", b"err-line\n", false, 1);
1492        assert!(result.contains("[exit code 1]"));
1493        assert!(result.contains("stdout:"));
1494        assert!(result.contains("out-line"));
1495        assert!(result.contains("stderr:"));
1496        assert!(result.contains("err-line"));
1497    }
1498
1499    #[test]
1500    fn format_command_output_non_zero_exit_omits_empty_streams() {
1501        // Whitespace-only streams are treated as empty and neither the
1502        // stdout: nor stderr: block is emitted.
1503        let result = BuiltinTools::format_command_output(b"   \n", b"", false, 2);
1504        assert_eq!(result, "[exit code 2]\n");
1505    }
1506
1507    #[test]
1508    fn format_command_output_success_with_output_returns_stdout() {
1509        let result = BuiltinTools::format_command_output(b"hello\n", b"", true, 0);
1510        assert_eq!(result, "hello\n");
1511    }
1512
1513    #[test]
1514    fn format_command_output_success_no_output() {
1515        let result = BuiltinTools::format_command_output(b"   ", b"noise", true, 0);
1516        assert_eq!(result, "(command succeeded with no output)");
1517    }
1518
1519    // ─── Bounded shell capture (issue #252) ──────────────────────────────────
1520
1521    use crate::exec::{
1522        Captured, MAX_CAPTURE_BYTES, MAX_READ_FILE_BYTES, cap_file_content, capture_capped,
1523        capture_note,
1524    };
1525
1526    /// A reader that hands back `chunk` `count` times and records how many
1527    /// reads it was asked for, standing in for a child's pipe.
1528    struct CountingReader {
1529        remaining: usize,
1530        chunk: usize,
1531        reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1532    }
1533
1534    impl tokio::io::AsyncRead for CountingReader {
1535        fn poll_read(
1536            mut self: std::pin::Pin<&mut Self>,
1537            _cx: &mut std::task::Context<'_>,
1538            buf: &mut tokio::io::ReadBuf<'_>,
1539        ) -> std::task::Poll<std::io::Result<()>> {
1540            self.reads
1541                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1542            if self.remaining == 0 {
1543                return std::task::Poll::Ready(Ok(()));
1544            }
1545            let n = self.chunk.min(self.remaining).min(buf.remaining());
1546            buf.put_slice(&vec![b'x'; n]);
1547            self.remaining -= n;
1548            std::task::Poll::Ready(Ok(()))
1549        }
1550    }
1551
1552    #[tokio::test]
1553    async fn capture_capped_keeps_the_cap_and_counts_what_it_dropped() {
1554        let payload = vec![b'a'; 5000];
1555        let mut source = &payload[..];
1556        let got = capture_capped(&mut source, 100).await;
1557        assert_eq!(got.kept.len(), 100);
1558        assert_eq!(got.total, 5000);
1559    }
1560
1561    #[tokio::test]
1562    async fn capture_capped_keeps_everything_under_the_cap() {
1563        let payload = [b'a'; 40];
1564        let mut source = &payload[..];
1565        let got = capture_capped(&mut source, 100).await;
1566        assert_eq!(got.kept.len(), 40);
1567        assert_eq!(got.total, 40);
1568    }
1569
1570    /// The property the whole design rests on. A reader that stopped at the cap
1571    /// would leave the child blocked on a full pipe, so a command producing
1572    /// more than the cap would stop making progress and die at the timeout
1573    /// instead of returning a truncated answer.
1574    #[tokio::test]
1575    async fn capture_capped_drains_past_the_cap_so_the_child_never_blocks() {
1576        let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1577        let mut source = CountingReader {
1578            remaining: 10_000,
1579            chunk: 1_000,
1580            reads: reads.clone(),
1581        };
1582        let got = capture_capped(&mut source, 100).await;
1583        assert_eq!(got.total, 10_000, "the tail was not read");
1584        assert_eq!(got.kept.len(), 100);
1585        // Ten chunks plus the final empty read that signals EOF.
1586        assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 11);
1587    }
1588
1589    /// A broken pipe ends the capture and keeps what arrived before it, rather
1590    /// than discarding a completed command's output and reporting a spawn
1591    /// failure for a command that actually ran.
1592    #[tokio::test]
1593    async fn capture_capped_treats_a_read_error_as_the_end_of_the_output() {
1594        struct FailsAfterOne(bool);
1595        impl tokio::io::AsyncRead for FailsAfterOne {
1596            fn poll_read(
1597                mut self: std::pin::Pin<&mut Self>,
1598                _cx: &mut std::task::Context<'_>,
1599                buf: &mut tokio::io::ReadBuf<'_>,
1600            ) -> std::task::Poll<std::io::Result<()>> {
1601                if self.0 {
1602                    return std::task::Poll::Ready(Err(std::io::Error::other("pipe broke")));
1603                }
1604                self.0 = true;
1605                buf.put_slice(b"partial");
1606                std::task::Poll::Ready(Ok(()))
1607            }
1608        }
1609        let mut source = FailsAfterOne(false);
1610        let got = capture_capped(&mut source, 100).await;
1611        assert_eq!(got.kept, b"partial");
1612        assert_eq!(got.total, 7);
1613    }
1614
1615    fn captured(kept: usize, total: u64) -> Captured {
1616        Captured {
1617            kept: vec![b'x'; kept],
1618            total,
1619        }
1620    }
1621
1622    #[test]
1623    fn capture_note_is_silent_when_nothing_was_dropped() {
1624        assert!(capture_note(&captured(10, 10), &captured(0, 0), 10).is_none());
1625    }
1626
1627    // ─── read_file has a bound ──────────────────────────────────────────────
1628
1629    #[test]
1630    fn a_file_under_the_cap_comes_back_whole() {
1631        let content = "hello".repeat(10);
1632        assert_eq!(cap_file_content(&content, 1024), content);
1633    }
1634
1635    #[test]
1636    fn a_file_over_the_cap_is_truncated_and_says_so() {
1637        // The old behaviour was an all-or-nothing cliff: the whole file went
1638        // into the routed region, and the ladder in `tool_results` either
1639        // truncated it or dropped it as `[result omitted]` depending on how
1640        // full the region already was.
1641        let content = "x".repeat(5000);
1642        let capped = cap_file_content(&content, 1000);
1643        assert!(capped.starts_with(&"x".repeat(1000)));
1644        assert!(capped.contains("[truncated]"), "{capped}");
1645        assert!(
1646            capped.contains("5000"),
1647            "the real size is the useful part: {capped}"
1648        );
1649    }
1650
1651    #[test]
1652    fn truncation_lands_on_a_char_boundary() {
1653        // The cap is a byte count and file content is arbitrary text, so a
1654        // naive slice would panic on the way back to a `String`.
1655        let content = "é".repeat(100);
1656        let capped = cap_file_content(&content, 51);
1657        assert!(capped.starts_with("é"));
1658        assert!(capped.contains("[truncated]"));
1659    }
1660
1661    #[tokio::test]
1662    async fn read_file_applies_the_cap() {
1663        let dir = tempfile::tempdir().unwrap();
1664        std::fs::write(
1665            dir.path().join("big.txt"),
1666            "y".repeat(MAX_READ_FILE_BYTES + 4096),
1667        )
1668        .unwrap();
1669        let tools = make_tools(dir.path());
1670        let out = tools.read_file(&json!({ "path": "big.txt" })).await;
1671        assert!(out.contains("[truncated]"), "an unbounded read is the bug");
1672        assert!(out.len() < MAX_READ_FILE_BYTES + 4096);
1673    }
1674
1675    #[test]
1676    fn capture_note_names_whichever_stream_overran() {
1677        let over = captured(10, 5_000);
1678        let fine = captured(10, 10);
1679        let stdout_only = capture_note(&over, &fine, 10).expect("stdout overran");
1680        assert!(stdout_only.contains("stdout exceeded"), "{stdout_only}");
1681        let stderr_only = capture_note(&fine, &over, 10).expect("stderr overran");
1682        assert!(stderr_only.contains("stderr exceeded"), "{stderr_only}");
1683        let both = capture_note(&over, &over, 10).expect("both overran");
1684        assert!(both.contains("stdout and stderr exceeded"), "{both}");
1685        // The count is everything the command wrote, not what survived.
1686        assert!(both.contains("10000 bytes"), "{both}");
1687    }
1688
1689    /// The truncation wiring, driven through a real process on every platform.
1690    ///
1691    /// `echo hello` is the one flooding-free way to exceed a cap that both
1692    /// `cmd.exe` and `sh` understand, so the cap is injected rather than the
1693    /// output being made enormous. The `#[cfg(unix)]` test below is the
1694    /// real-megabyte twin.
1695    #[tokio::test]
1696    async fn a_command_that_outruns_the_cap_is_truncated_and_says_so() {
1697        let dir = tempfile::tempdir().unwrap();
1698        let tools = make_tools(dir.path());
1699        let result = tools
1700            .shell_with_limits(
1701                &json!({"command": "echo hello"}),
1702                Duration::from_secs(30),
1703                4,
1704            )
1705            .await;
1706        assert!(result.contains("[truncated]"), "{result}");
1707        assert!(result.contains("hell"), "{result}");
1708        assert!(!result.contains("[timed out]"), "{result}");
1709    }
1710
1711    /// The control: under a cap it comfortably fits, nothing is said about
1712    /// truncation. Without this the test above passes against a version that
1713    /// always appends the note.
1714    #[tokio::test]
1715    async fn a_command_within_the_cap_gets_no_truncation_note() {
1716        let dir = tempfile::tempdir().unwrap();
1717        let tools = make_tools(dir.path());
1718        let result = tools
1719            .shell_with_limits(
1720                &json!({"command": "echo hello"}),
1721                Duration::from_secs(30),
1722                MAX_CAPTURE_BYTES,
1723            )
1724            .await;
1725        assert!(result.contains("hello"), "{result}");
1726        assert!(!result.contains("[truncated]"), "{result}");
1727    }
1728
1729    /// The end-to-end twin: a real command that outproduces the cap comes back
1730    /// truncated and *successful*, not timed out.
1731    #[cfg(unix)]
1732    #[tokio::test]
1733    async fn a_command_that_floods_stdout_is_truncated_rather_than_timing_out() {
1734        let dir = tempfile::tempdir().unwrap();
1735        let tools = make_tools(dir.path());
1736        let result = tools
1737            .shell_with_timeout(
1738                &json!({"command": "head -c 3000000 /dev/zero | tr '\\0' 'x'"}),
1739                Duration::from_secs(30),
1740            )
1741            .await;
1742        assert!(result.contains("[truncated]"));
1743        assert!(!result.contains("[timed out]"));
1744        // Kept the cap, plus the note. Nothing near the 3 MB the command wrote.
1745        let ceiling = MAX_CAPTURE_BYTES + 1000;
1746        assert!(result.len() < ceiling);
1747    }
1748
1749    #[tokio::test]
1750    async fn shell_with_timeout_fires_on_slow_command() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let tools = make_tools(dir.path());
1753        let result = tools
1754            .shell_with_timeout(&json!({"command": "sleep 5"}), Duration::from_millis(100))
1755            .await;
1756        assert!(result.contains("[timed out]"));
1757    }
1758
1759    /// A timed-out (or cancelled) command takes its *grandchildren* with it.
1760    ///
1761    /// `kill_on_drop` only reaps the shell. Anything the shell started is
1762    /// reparented to init and keeps running - a cancelled agent's `sleep`
1763    /// outliving the run that spawned it. Verified by writing a marker file
1764    /// after a delay: if the grandchild survived, the marker appears.
1765    #[cfg(unix)]
1766    #[tokio::test]
1767    async fn a_timed_out_command_kills_its_grandchildren() {
1768        let dir = tempfile::tempdir().unwrap();
1769        let marker = dir.path().join("survived");
1770        let tools = make_tools(dir.path());
1771
1772        // A *backgrounded subshell* is the grandchild, and it is what writes the
1773        // marker. Chaining (`sleep 2 && touch`) would not test anything: the
1774        // `touch` is run by the shell itself, so killing the shell suppresses it
1775        // whether or not the group was signalled.
1776        let cmd = format!("( sleep 2; touch {} ) & sleep 30", marker.display());
1777        let result = tools
1778            .shell_with_timeout(&json!({ "command": cmd }), Duration::from_millis(100))
1779            .await;
1780        assert!(result.contains("[timed out]"), "got: {result}");
1781
1782        // Well past when the grandchild would have written it.
1783        tokio::time::sleep(Duration::from_secs(3)).await;
1784        assert!(
1785            !marker.exists(),
1786            "the grandchild outlived the command that started it"
1787        );
1788    }
1789
1790    #[tokio::test]
1791    async fn shell_spawn_failure_when_workdir_missing() {
1792        // A workdir that doesn't exist on disk makes Command::output() fail
1793        // before the shell ever runs (current_dir() can't chdir into it).
1794        // canonicalize() fails for a nonexistent path, so ToolContext::new()
1795        // falls back to keeping the raw (nonexistent) path as-is.
1796        let tools = make_tools(std::path::Path::new(
1797            "/definitely/does/not/exist/leviath-test",
1798        ));
1799        let result = tools.execute("shell", json!({"command": "echo hi"})).await;
1800        assert!(result.contains("[error]"));
1801        assert!(result.contains("Failed to spawn shell"));
1802    }
1803
1804    // ── ToolContext ────────────────────────────────────────────────────────
1805
1806    #[test]
1807    fn tool_context_new_canonicalizes() {
1808        let dir = std::env::temp_dir();
1809        let ctx = ToolContext::new(dir.clone());
1810        // Canonicalized path should be absolute
1811        assert!(ctx.workdir.is_absolute());
1812    }
1813
1814    #[test]
1815    fn tool_context_new_with_nonexistent_dir() {
1816        let ctx = ToolContext::new(PathBuf::from("/nonexistent/path/unlikely"));
1817        // Falls back to the original path when canonicalization fails
1818        assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1819    }
1820
1821    // ── detect_shell ──────────────────────────────────────────────────────
1822
1823    /// The Windows answer, asserted from every platform now that the OS is a
1824    /// parameter rather than a `#[cfg]`. `$SHELL` is ignored there even when it
1825    /// is set (Git for Windows sets it to an MSYS path `CreateProcess` cannot
1826    /// run), which is what the second call pins.
1827    #[test]
1828    fn detect_shell_returns_cmd_exe_on_windows() {
1829        let (shell, flag) = BuiltinTools::detect_shell_for("windows", None, &|_| true);
1830        assert_eq!(shell, "cmd.exe");
1831        assert_eq!(flag, "/C");
1832
1833        let (shell, _) =
1834            BuiltinTools::detect_shell_for("windows", Some("/usr/bin/bash".to_string()), &|_| true);
1835        assert_eq!(shell, "cmd.exe", "$SHELL is not consulted on Windows");
1836    }
1837
1838    #[test]
1839    fn detect_shell_returns_valid_shell() {
1840        // Pure reader: `detect_shell()` always returns a non-empty shell (and the
1841        // "-c" flag on non-Windows) regardless of $SHELL, so it is robust to a
1842        // concurrent temp-env writer and needs no serialization of its own.
1843        let (shell, flag) = BuiltinTools::detect_shell();
1844        assert!(!shell.is_empty());
1845        assert!(!flag.is_empty());
1846        #[cfg(not(windows))]
1847        assert_eq!(flag, "-c");
1848    }
1849
1850    /// Drives the real filesystem probe (`shell_path_exists`) through the seam,
1851    /// with an unrecognized `$SHELL` so the candidate loop is reached. Passing
1852    /// `"linux"` rather than the host OS is what lets this run on the Windows
1853    /// leg too - production's probe would otherwise be a function no Windows
1854    /// test ever calls.
1855    ///
1856    /// The result is host-dependent: a Unix host finds one of the candidates,
1857    /// a Windows host finds none and falls to the last resort. Both are correct,
1858    /// so only the shape is asserted.
1859    #[test]
1860    fn detect_shell_queries_the_real_filesystem_for_an_unrecognized_shell() {
1861        let (shell, flag) = BuiltinTools::detect_shell_for(
1862            "linux",
1863            Some("/opt/not-a-recognized-shell".to_string()),
1864            &BuiltinTools::shell_path_exists,
1865        );
1866        assert_eq!(flag, "-c");
1867        assert!(
1868            [
1869                "/bin/bash",
1870                "/usr/bin/bash",
1871                "/bin/zsh",
1872                "/usr/bin/zsh",
1873                "/bin/sh",
1874                "sh",
1875            ]
1876            .contains(&shell),
1877            "unexpected shell: {shell}"
1878        );
1879    }
1880
1881    // ── detect_shell_for() - inject OS, env and filesystem for full branch coverage ──
1882
1883    #[test]
1884    fn detect_shell_for_returns_zsh_from_env() {
1885        // `$SHELL` is trusted only when it exists on disk.
1886        let (shell, flag) =
1887            BuiltinTools::detect_shell_for("linux", Some("/usr/local/bin/zsh".to_string()), &|s| {
1888                s == "/usr/local/bin/zsh"
1889            });
1890        assert_eq!(shell, "/usr/local/bin/zsh");
1891        assert_eq!(flag, "-c");
1892    }
1893
1894    #[test]
1895    fn detect_shell_for_returns_bash_from_env() {
1896        let (shell, flag) = BuiltinTools::detect_shell_for(
1897            "macos",
1898            Some("/usr/local/bin/bash".to_string()),
1899            &|s| s == "/usr/local/bin/bash",
1900        );
1901        assert_eq!(shell, "/usr/local/bin/bash");
1902        assert_eq!(flag, "-c");
1903    }
1904
1905    #[test]
1906    fn detect_shell_for_returns_sh_from_env() {
1907        // An OS nobody special-cases still gets the POSIX treatment rather than
1908        // falling into the Windows arm.
1909        let (shell, flag) =
1910            BuiltinTools::detect_shell_for("freebsd", Some("/usr/bin/sh".to_string()), &|s| {
1911                s == "/usr/bin/sh"
1912            });
1913        assert_eq!(shell, "/usr/bin/sh");
1914        assert_eq!(flag, "-c");
1915    }
1916
1917    #[test]
1918    fn detect_shell_for_falls_back_when_env_shell_is_missing() {
1919        // Regression for #79: `$SHELL` is a recognized shell name but does not
1920        // exist on disk (a stale or sandbox-missing `/bin/zsh`). It must NOT be
1921        // returned - fall through to an available fallback instead of failing
1922        // every shell call with "No such file or directory".
1923        let (shell, flag) =
1924            BuiltinTools::detect_shell_for("linux", Some("/bin/zsh".to_string()), &|s| {
1925                s == "/bin/sh"
1926            });
1927        assert_eq!(shell, "/bin/sh");
1928        assert_eq!(flag, "-c");
1929    }
1930
1931    #[test]
1932    fn detect_shell_for_falls_through_when_env_unrecognized() {
1933        // /opt/fish doesn't end with /zsh, /bash, or /sh → falls to candidate loop
1934        let (shell, flag) =
1935            BuiltinTools::detect_shell_for("linux", Some("/opt/fish".to_string()), &|s| {
1936                s == "/bin/bash"
1937            });
1938        assert_eq!(shell, "/bin/bash");
1939        assert_eq!(flag, "-c");
1940    }
1941
1942    #[test]
1943    fn detect_shell_for_skips_missing_candidates_and_finds_zsh() {
1944        // bash paths return false; /bin/zsh exists - covers shell_exists false branch
1945        let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|s| s == "/bin/zsh");
1946        assert_eq!(shell, "/bin/zsh");
1947        assert_eq!(flag, "-c");
1948    }
1949
1950    #[test]
1951    fn detect_shell_for_returns_last_resort_when_nothing_exists() {
1952        let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|_| false);
1953        assert_eq!(shell, "sh");
1954        assert_eq!(flag, "-c");
1955    }
1956
1957    #[tokio::test]
1958    async fn concurrent_edits_same_file_serialize_no_lost_update() {
1959        // Two workers edit different unique strings in the SAME file at once.
1960        // The per-path lock serializes the read-modify-write, so both edits
1961        // land; without it, the second write would clobber the first.
1962        let dir = tempfile::tempdir().unwrap();
1963        std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1964        let tools = std::sync::Arc::new(make_tools(dir.path()));
1965
1966        let t1 = {
1967            let t = tools.clone();
1968            tokio::spawn(async move {
1969                t.execute(
1970                    "edit_file",
1971                    json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1972                )
1973                .await
1974            })
1975        };
1976        let t2 = {
1977            let t = tools.clone();
1978            tokio::spawn(async move {
1979                t.execute(
1980                    "edit_file",
1981                    json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1982                )
1983                .await
1984            })
1985        };
1986        let (r1, r2) = tokio::join!(t1, t2);
1987        assert!(!r1.unwrap().starts_with("[error]"));
1988        assert!(!r2.unwrap().starts_with("[error]"));
1989
1990        let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1991        assert_eq!(
1992            final_content, "A1\nB2\n",
1993            "both concurrent edits must apply (no lost update)"
1994        );
1995    }
1996
1997    #[tokio::test]
1998    async fn concurrent_writes_different_files_both_succeed() {
1999        // Different files never contend on the per-path lock.
2000        let dir = tempfile::tempdir().unwrap();
2001        let tools = std::sync::Arc::new(make_tools(dir.path()));
2002
2003        let a = {
2004            let t = tools.clone();
2005            tokio::spawn(async move {
2006                t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
2007                    .await
2008            })
2009        };
2010        let b = {
2011            let t = tools.clone();
2012            tokio::spawn(async move {
2013                t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
2014                    .await
2015            })
2016        };
2017        let (ra, rb) = tokio::join!(a, b);
2018        assert!(!ra.unwrap().starts_with("[error]"));
2019        assert!(!rb.unwrap().starts_with("[error]"));
2020        assert_eq!(
2021            std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
2022            "AAA"
2023        );
2024        assert_eq!(
2025            std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
2026            "BBB"
2027        );
2028    }
2029
2030    // ── Platform capabilities ─────────────────────────────────────────────
2031
2032    #[test]
2033    fn desktop_supports_all_capabilities() {
2034        let caps = PlatformCapabilities::desktop();
2035        assert!(caps.supports(ToolCapability::ProcessSpawn));
2036        assert!(caps.supports(ToolCapability::FileSystem));
2037        assert!(caps.supports(ToolCapability::Network));
2038    }
2039
2040    #[test]
2041    fn mobile_lacks_process_spawn() {
2042        let caps = PlatformCapabilities::mobile();
2043        assert!(!caps.supports(ToolCapability::ProcessSpawn));
2044        assert!(caps.supports(ToolCapability::FileSystem));
2045        assert!(caps.supports(ToolCapability::Network));
2046    }
2047
2048    #[test]
2049    fn current_matches_desktop_and_is_the_default() {
2050        // Only desktop targets are built today.
2051        assert_eq!(
2052            PlatformCapabilities::current(),
2053            PlatformCapabilities::desktop()
2054        );
2055        assert_eq!(
2056            PlatformCapabilities::default(),
2057            PlatformCapabilities::desktop()
2058        );
2059    }
2060
2061    #[test]
2062    fn satisfies_requires_all_and_empty_is_always_met() {
2063        let caps = PlatformCapabilities::mobile();
2064        assert!(caps.satisfies(&[]));
2065        assert!(caps.satisfies(&[ToolCapability::FileSystem]));
2066        assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
2067        // All-or-nothing: one unmet requirement fails the whole set.
2068        assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
2069    }
2070
2071    #[test]
2072    fn from_capabilities_builds_explicit_set() {
2073        let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
2074        assert!(caps.supports(ToolCapability::Network));
2075        assert!(!caps.supports(ToolCapability::FileSystem));
2076    }
2077
2078    #[test]
2079    fn tool_required_capabilities_by_name() {
2080        assert_eq!(
2081            tool_required_capabilities("shell"),
2082            &[ToolCapability::ProcessSpawn]
2083        );
2084        assert_eq!(
2085            tool_required_capabilities("read_file"),
2086            &[ToolCapability::FileSystem]
2087        );
2088        // Runtime-handled / platform-agnostic tools require nothing.
2089        assert!(tool_required_capabilities("context_write").is_empty());
2090        assert!(tool_required_capabilities("present_for_review").is_empty());
2091        assert!(tool_required_capabilities("unknown_tool").is_empty());
2092    }
2093
2094    #[test]
2095    fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
2096        let dir = std::env::temp_dir();
2097        let tools = make_mobile_tools(&dir);
2098        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
2099        assert!(!names.contains(&"shell".to_string()));
2100        // The other 16 built-ins remain.
2101        assert_eq!(tools.tool_defs().len(), 19);
2102        assert!(names.contains(&"read_file".to_string()));
2103        assert!(names.contains(&"context_write".to_string()));
2104        assert!(names.contains(&"present_for_review".to_string()));
2105    }
2106
2107    #[test]
2108    fn desktop_tool_defs_include_shell() {
2109        let dir = std::env::temp_dir();
2110        let tools = make_tools(&dir);
2111        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
2112        assert!(names.contains(&"shell".to_string()));
2113    }
2114
2115    #[test]
2116    fn mobile_names_omit_shell_and_bash_alias() {
2117        let dir = std::env::temp_dir();
2118        let tools = make_mobile_tools(&dir);
2119        let names = tools.names();
2120        assert!(!names.contains(&"shell".to_string()));
2121        assert!(!names.contains(&"bash".to_string()));
2122        // File + context tools still recognized.
2123        assert!(names.contains(&"read_file".to_string()));
2124        assert!(names.contains(&"context_write".to_string()));
2125    }
2126
2127    #[test]
2128    fn desktop_names_include_shell_and_bash_alias() {
2129        let dir = std::env::temp_dir();
2130        let tools = make_tools(&dir);
2131        let names = tools.names();
2132        assert!(names.contains(&"shell".to_string()));
2133        assert!(names.contains(&"bash".to_string()));
2134    }
2135
2136    #[tokio::test]
2137    async fn mobile_execute_shell_is_rejected() {
2138        let dir = tempfile::tempdir().unwrap();
2139        let tools = make_mobile_tools(dir.path());
2140        let out = tools.execute("shell", json!({"command": "echo hi"})).await;
2141        assert!(out.contains("not available on this platform"), "got: {out}");
2142        // The `bash` alias resolves to `shell` and is rejected the same way.
2143        let out = tools.execute("bash", json!({"command": "echo hi"})).await;
2144        assert!(out.contains("not available on this platform"), "got: {out}");
2145    }
2146
2147    #[tokio::test]
2148    async fn mobile_execute_file_tool_still_works() {
2149        let dir = tempfile::tempdir().unwrap();
2150        let tools = make_mobile_tools(dir.path());
2151        let out = tools
2152            .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
2153            .await;
2154        assert!(!out.starts_with("[error]"), "got: {out}");
2155        assert_eq!(
2156            std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
2157            "hi"
2158        );
2159    }
2160
2161    // ─── The null device is not an escape (#373) ─────────────────────────────────
2162
2163    /// Writing to the null device writes nowhere, so containment has nothing to
2164    /// refuse. It used to answer `path '/dev/null' would escape the working
2165    /// directory`, which is both wrong and unfixable from the agent's side: there
2166    /// is no path inside the workspace that means "discard this".
2167    #[tokio::test]
2168    async fn write_file_to_the_null_device_is_allowed() {
2169        let dir = tempfile::tempdir().unwrap();
2170        let tools = make_tools(dir.path());
2171        let result = tools
2172            .execute(
2173                "write_file",
2174                json!({"path": "/dev/null", "content": "thrown away"}),
2175            )
2176            .await;
2177        assert!(
2178            !result.contains("escape"),
2179            "the null device is not an escape: {result}"
2180        );
2181    }
2182
2183    #[tokio::test]
2184    async fn read_file_from_the_null_device_is_allowed() {
2185        let dir = tempfile::tempdir().unwrap();
2186        let tools = make_tools(dir.path());
2187        let result = tools
2188            .execute("read_file", json!({"path": "/dev/null"}))
2189            .await;
2190        assert!(
2191            !result.contains("escape"),
2192            "the null device is not an escape: {result}"
2193        );
2194    }
2195
2196    /// The control, so the allowance above cannot be mistaken for containment
2197    /// having been switched off: a real path outside the workspace is still
2198    /// refused.
2199    #[tokio::test]
2200    async fn a_real_outside_path_is_still_refused() {
2201        let dir = tempfile::tempdir().unwrap();
2202        let tools = make_tools(dir.path());
2203        let result = tools
2204            .execute("write_file", json!({"path": "../out.txt", "content": "x"}))
2205            .await;
2206        assert!(result.contains("escape"), "got: {result}");
2207    }
2208
2209    /// `/dev/stdout` and `/dev/stderr` are not sinks, on purpose. Opened by
2210    /// name from inside the daemon they are its own streams, so a tool writing
2211    /// there lands in the middle of whatever the CLI is drawing. A shell
2212    /// redirect to them is a different thing spelled the same way and stays
2213    /// allowed.
2214    ///
2215    /// Asserted against the predicate rather than through a tool call: on
2216    /// Windows a `/dev/...` path is relative, so a call would be judged against
2217    /// the workdir and the test would be measuring the platform's path rules
2218    /// rather than this one.
2219    #[test]
2220    fn the_daemons_own_streams_are_not_null_devices() {
2221        assert!(is_null_device("/dev/null"), "the sink is a sink");
2222        assert!(is_null_device("NUL"), "and so is the Windows spelling");
2223        assert!(is_null_device("nul"), "case does not decide it");
2224        assert!(!is_null_device("/dev/stdout"));
2225        assert!(!is_null_device("/dev/stderr"));
2226        assert!(
2227            !is_null_device("notes.md"),
2228            "an ordinary path is not a sink"
2229        );
2230    }
2231
2232    /// A refusal names the workspace and what to do about it. An agent told only
2233    /// "denied" tries a different escape; one told where to write complies, and the
2234    /// turns it would have spent guessing are charged to the stage's budget.
2235    #[tokio::test]
2236    async fn an_escape_refusal_says_where_to_write_instead() {
2237        let dir = tempfile::tempdir().unwrap();
2238        let tools = make_tools(dir.path());
2239        let result = tools
2240            .execute("write_file", json!({"path": "../out.txt", "content": "x"}))
2241            .await;
2242        // The tempdir's own directory name rather than its full path: Windows
2243        // canonicalizes a temp path (verbatim prefix, short names), so the
2244        // workdir in the message is not textually the string `display()`
2245        // returns here. The unique final component survives that.
2246        let leaf = dir
2247            .path()
2248            .file_name()
2249            .expect("a temp dir has a name")
2250            .to_string_lossy()
2251            .to_string();
2252        assert!(result.contains(&leaf), "names the workspace root: {result}");
2253        assert!(
2254            result.contains("inside the workspace"),
2255            "says what to do instead: {result}"
2256        );
2257    }
2258}