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;
19mod platform;
20mod validate;
21pub use context::*;
22pub use defs::{SUBAGENT_TOOLS, is_subagent_tool};
23pub use platform::*;
24pub use validate::*;
25
26/// Built-in tools: read_file, write_file, edit_file, list_dir, shell.
27///
28/// Carries the [`PlatformCapabilities`] of the current platform; tools whose
29/// [`tool_required_capabilities`] aren't satisfied are dropped from
30/// [`tool_defs`](Self::tool_defs), [`names`](Self::names), and rejected by
31/// [`execute`](Self::execute).
32pub struct BuiltinTools {
33    ctx: ToolContext,
34    platform: PlatformCapabilities,
35    /// When set, shell commands run through this sandbox instead of the host.
36    shell_executor: Option<Arc<dyn ShellExecutor>>,
37}
38
39impl BuiltinTools {
40    /// Create a new BuiltinTools instance with the given sandbox context,
41    /// filtering tools against the current platform's capabilities.
42    pub fn new(ctx: ToolContext) -> Self {
43        Self {
44            ctx,
45            platform: PlatformCapabilities::current(),
46            shell_executor: None,
47        }
48    }
49
50    /// Route this agent's shell execution through `executor` (a container /
51    /// namespace sandbox) instead of the host.
52    pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
53        self.shell_executor = Some(executor);
54        self
55    }
56
57    /// Create a BuiltinTools instance with an explicit platform capability set,
58    /// for tests or hosts that need to override the compile-time default.
59    pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
60        Self {
61            ctx,
62            platform,
63            shell_executor: None,
64        }
65    }
66
67    /// Whether a built-in named `canonical_name` is available on this platform.
68    fn available(&self, canonical_name: &str) -> bool {
69        self.platform
70            .satisfies(tool_required_capabilities(canonical_name))
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::fs;
78
79    fn make_tools(dir: &std::path::Path) -> BuiltinTools {
80        BuiltinTools::new(ToolContext::new(dir.to_path_buf()))
81    }
82
83    /// Built-ins over a mobile capability set (no `ProcessSpawn`), so the
84    /// `shell` tool and its `bash` alias are filtered out.
85    fn make_mobile_tools(dir: &std::path::Path) -> BuiltinTools {
86        BuiltinTools::with_capabilities(
87            ToolContext::new(dir.to_path_buf()),
88            PlatformCapabilities::mobile(),
89        )
90    }
91
92    #[test]
93    fn the_shell_tool_advertises_the_shell_this_host_resolved() {
94        // Whichever shell the host has, the description has to name *it*: a
95        // model that reads "cmd" and gets zsh (or the reverse) writes the wrong
96        // commands, which is exactly the failure this replaced.
97        let dir = tempfile::tempdir().unwrap();
98        let defs = make_tools(dir.path()).tool_defs();
99        let shell = defs
100            .iter()
101            .find(|t| t.name == "shell")
102            .expect("shell is advertised on a desktop capability set");
103        let (resolved, _) = BuiltinTools::detect_shell();
104        assert!(
105            shell.description.contains(resolved),
106            "description {:?} does not name the resolved shell {resolved:?}",
107            shell.description
108        );
109
110        // Both platforms' wordings, without needing to run on both.
111        assert!(crate::defs::shell_tool_description("cmd.exe").contains("`cmd.exe`"));
112        assert!(crate::defs::shell_tool_description("/bin/zsh").contains("`/bin/zsh`"));
113    }
114
115    #[test]
116    fn subagent_predicate_covers_the_five_names_and_nothing_else() {
117        for name in SUBAGENT_TOOLS {
118            assert!(is_subagent_tool(name));
119        }
120        assert!(!is_subagent_tool("read_file"));
121    }
122
123    // ── Tool definitions ──────────────────────────────────────────────────
124
125    #[test]
126    fn tool_defs_returns_sixteen_tools() {
127        let dir = std::env::temp_dir();
128        let tools = make_tools(&dir);
129        let defs = tools.tool_defs();
130        assert_eq!(defs.len(), 16);
131    }
132
133    #[test]
134    fn tool_defs_names_are_correct() {
135        let dir = std::env::temp_dir();
136        let tools = make_tools(&dir);
137        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
138        assert!(names.contains(&"read_file".to_string()));
139        assert!(names.contains(&"read_files".to_string()));
140        assert!(names.contains(&"write_file".to_string()));
141        assert!(names.contains(&"edit_file".to_string()));
142        assert!(names.contains(&"list_dir".to_string()));
143        assert!(names.contains(&"shell".to_string()));
144        assert!(names.contains(&"present_for_review".to_string()));
145        assert!(names.contains(&"ask_user_text".to_string()));
146        assert!(names.contains(&"ask_user_choice".to_string()));
147        assert!(names.contains(&"ask_user_confirm".to_string()));
148        assert!(names.contains(&"edit_document".to_string()));
149        assert!(names.contains(&"context_write".to_string()));
150        assert!(names.contains(&"context_append".to_string()));
151        assert!(names.contains(&"context_read".to_string()));
152        assert!(names.contains(&"context_delete".to_string()));
153        assert!(names.contains(&"context_list".to_string()));
154    }
155
156    #[test]
157    fn tool_defs_edit_document_requires_content() {
158        let dir = std::env::temp_dir();
159        let tools = make_tools(&dir);
160        let def = tools
161            .tool_defs()
162            .into_iter()
163            .find(|t| t.name == "edit_document")
164            .expect("edit_document tool def must exist");
165        let required = def.parameters["required"].as_array().unwrap();
166        assert!(required.iter().any(|v| v == "content"));
167        assert_eq!(def.parameters["properties"]["content"]["type"], "string");
168        // Also present in the builtin name list.
169        assert!(tools.names().contains(&"edit_document".to_string()));
170    }
171
172    #[test]
173    fn tool_defs_ask_user_choice_has_options_array() {
174        let dir = std::env::temp_dir();
175        let tools = make_tools(&dir);
176        let def = tools
177            .tool_defs()
178            .into_iter()
179            .find(|t| t.name == "ask_user_choice")
180            .unwrap();
181        let required = def.parameters["required"].as_array().unwrap();
182        assert!(required.iter().any(|v| v == "prompt"));
183        assert!(required.iter().any(|v| v == "options"));
184        assert_eq!(def.parameters["properties"]["options"]["type"], "array");
185    }
186
187    #[tokio::test]
188    async fn context_tools_return_runtime_error() {
189        let dir = std::env::temp_dir();
190        let tools = make_tools(&dir);
191        for name in [
192            "context_write",
193            "context_append",
194            "context_read",
195            "context_delete",
196            "context_list",
197        ] {
198            let result = tools.execute(name, serde_json::json!({})).await;
199            assert!(result.contains("context tools must be handled by the runtime"));
200        }
201    }
202
203    #[tokio::test]
204    async fn ask_user_tools_not_handled_by_builtin_execute() {
205        // ask_user_* tools are intercepted upstream (worker.rs/foreground.rs),
206        // exactly like present_for_review - execute() must never run them.
207        let dir = std::env::temp_dir();
208        let tools = make_tools(&dir);
209        for name in [
210            "ask_user_text",
211            "ask_user_choice",
212            "ask_user_confirm",
213            "edit_document",
214        ] {
215            let result = tools.execute(name, serde_json::json!({})).await;
216            assert!(result.contains("Unknown built-in tool"));
217        }
218    }
219
220    #[test]
221    fn context_tool_descriptions_mention_key_concepts() {
222        let dir = std::env::temp_dir();
223        let tools = make_tools(&dir);
224        let defs = tools.tool_defs();
225
226        let write_def = defs.iter().find(|t| t.name == "context_write").unwrap();
227        assert!(
228            write_def.description.contains("system prompt"),
229            "context_write should mention system prompt: {}",
230            write_def.description
231        );
232        assert!(
233            write_def.description.contains("replaced"),
234            "context_write should mention replacement: {}",
235            write_def.description
236        );
237
238        let read_def = defs.iter().find(|t| t.name == "context_read").unwrap();
239        assert!(
240            read_def.description.contains("summary"),
241            "context_read should mention summary: {}",
242            read_def.description
243        );
244
245        let list_def = defs.iter().find(|t| t.name == "context_list").unwrap();
246        assert!(
247            list_def.description.contains("token"),
248            "context_list should mention tokens: {}",
249            list_def.description
250        );
251
252        let append_def = defs.iter().find(|t| t.name == "context_append").unwrap();
253        assert!(
254            append_def.description.contains("without replacing"),
255            "context_append should mention 'without replacing': {}",
256            append_def.description
257        );
258    }
259
260    fn assert_has_description(name: &str, description: &str) {
261        assert!(
262            !description.is_empty(),
263            "tool {} has empty description",
264            name
265        );
266    }
267
268    fn assert_has_object_params(name: &str, params: &serde_json::Value) {
269        assert!(params.is_object(), "tool {} has non-object params", name);
270    }
271
272    #[test]
273    fn tool_defs_have_descriptions() {
274        let dir = std::env::temp_dir();
275        let tools = make_tools(&dir);
276        for def in tools.tool_defs() {
277            assert_has_description(&def.name, &def.description);
278        }
279    }
280
281    #[test]
282    #[should_panic(expected = "tool bogus has empty description")]
283    fn tool_defs_have_descriptions_panics_on_empty_description() {
284        assert_has_description("bogus", "");
285    }
286
287    #[test]
288    fn tool_defs_have_parameters() {
289        let dir = std::env::temp_dir();
290        let tools = make_tools(&dir);
291        for def in tools.tool_defs() {
292            assert_has_object_params(&def.name, &def.parameters);
293        }
294    }
295
296    #[test]
297    #[should_panic(expected = "tool bogus has non-object params")]
298    fn tool_defs_have_parameters_panics_on_non_object_params() {
299        assert_has_object_params("bogus", &serde_json::Value::Null);
300    }
301
302    // ── names() ───────────────────────────────────────────────────────────
303
304    #[test]
305    fn names_includes_bash_alias() {
306        let dir = std::env::temp_dir();
307        let tools = make_tools(&dir);
308        let names = tools.names();
309        assert!(names.contains(&"bash".to_string()));
310        assert!(names.contains(&"shell".to_string()));
311    }
312
313    #[test]
314    fn canonical_tool_name_resolves_aliases_and_passes_others_through() {
315        // An alias resolves to its canonical name.
316        assert_eq!(canonical_tool_name("bash"), "shell");
317        // A canonical built-in is unchanged.
318        assert_eq!(canonical_tool_name("shell"), "shell");
319        assert_eq!(canonical_tool_name("read_file"), "read_file");
320        // An unknown name (e.g. an MCP tool whose server may not be installed)
321        // passes through untouched, so it is matched/omitted as-is.
322        assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
323        // Every alias in the table round-trips to a real canonical name.
324        for (alias, canonical) in TOOL_ALIASES {
325            assert_eq!(canonical_tool_name(alias), *canonical);
326        }
327    }
328
329    #[test]
330    fn names_returns_seventeen_entries() {
331        let dir = std::env::temp_dir();
332        let tools = make_tools(&dir);
333        assert_eq!(tools.names().len(), 17);
334    }
335
336    // ── Sub-agent tool definitions ────────────────────────────────────────
337
338    #[test]
339    fn subagent_tool_defs_returns_five_tools() {
340        let defs = BuiltinTools::subagent_tool_defs();
341        assert_eq!(defs.len(), 5);
342    }
343
344    #[test]
345    fn subagent_tool_names_returns_five_names() {
346        let names = BuiltinTools::subagent_tool_names();
347        assert_eq!(names.len(), 5);
348        assert!(names.contains(&"spawn_agent".to_string()));
349        assert!(names.contains(&"check_agent".to_string()));
350        assert!(names.contains(&"wait_for_agent".to_string()));
351        assert!(names.contains(&"send_to_agent".to_string()));
352        assert!(names.contains(&"kill_agent".to_string()));
353    }
354
355    #[test]
356    fn subagent_tool_defs_names_match_subagent_tool_names() {
357        let defs = BuiltinTools::subagent_tool_defs();
358        let names = BuiltinTools::subagent_tool_names();
359        let def_names: Vec<String> = defs.iter().map(|d| d.name.clone()).collect();
360        assert_eq!(def_names, names);
361    }
362
363    // ── resolve() ─────────────────────────────────────────────────────────
364
365    #[test]
366    fn resolve_relative_path() {
367        let dir = std::env::temp_dir();
368        let tools = make_tools(&dir);
369        let result = tools.resolve("hello.txt").unwrap();
370        assert!(result.starts_with(&tools.ctx.workdir));
371        assert!(result.ends_with("hello.txt"));
372    }
373
374    #[test]
375    fn resolve_rejects_path_escape() {
376        let dir = std::env::temp_dir().join("leviath_test_sandbox");
377        fs::create_dir_all(&dir).ok();
378        let tools = make_tools(&dir);
379        let result = tools.resolve("../../etc/passwd");
380        assert!(result.is_err());
381    }
382
383    /// The escape a lexical check cannot see. `<workdir>/link -> /` makes
384    /// `link/etc/passwd` textually contained the whole way, and the old
385    /// `starts_with` containment let `fs::read_to_string` follow it straight out.
386    ///
387    /// This matters most where the containment is load-bearing: Leviath's file
388    /// tools run on the *host* over the bind-mounted workdir even when the
389    /// The containment refusal itself, driven through the injected predicate so
390    /// it is exercised on every platform. The `#[cfg(unix)]` tests below prove
391    /// the same refusal against a real symlink; this one proves the arm exists
392    /// and fires on Windows too, where a test cannot create one.
393    #[test]
394    fn resolve_refuses_a_path_that_does_not_resolve_within_the_workdir() {
395        fn escapes(_: &Path, _: &Path) -> bool {
396            false
397        }
398        let dir = tempfile::tempdir().unwrap();
399        let err = BuiltinTools::resolve_within("notes.txt", dir.path(), escapes)
400            .expect_err("a path that resolves outside must be refused");
401        assert!(err.to_string().contains("symlink"), "{err}");
402    }
403
404    /// The converse, so the test above is not passing merely because everything
405    /// is refused: with the real predicate an ordinary path resolves.
406    #[test]
407    fn resolve_admits_an_ordinary_path_within_the_workdir() {
408        let dir = tempfile::tempdir().unwrap();
409        let resolved =
410            BuiltinTools::resolve_within("notes.txt", dir.path(), leviath_core::resolves_within)
411                .expect("an ordinary path resolves");
412        assert!(resolved.ends_with("notes.txt"));
413    }
414
415    /// stage's `shell` is confined to a container, so a symlink the agent made
416    /// inside the container escaped the container through these tools. It is also
417    /// reachable from a checked-in symlink in a freshly cloned repository, which
418    /// is exactly what a coding agent is pointed at.
419    #[cfg(unix)]
420    #[tokio::test]
421    async fn resolve_rejects_symlink_escape() {
422        let dir = tempfile::tempdir().unwrap();
423        let workdir = dir.path().join("workspace");
424        fs::create_dir(&workdir).unwrap();
425        std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
426        let tools = make_tools(&workdir);
427
428        // Precondition: this is textually inside the workdir, so a lexical
429        // `starts_with` containment check alone would pass it.
430        // Built from `ctx.workdir` rather than `workdir` because the context
431        // canonicalizes (on macOS `/var` becomes `/private/var`).
432        let normalized = tools.ctx.workdir.join("link/etc/hosts");
433        assert!(normalized.starts_with(&tools.ctx.workdir));
434
435        let err = tools.resolve("link/etc/hosts").unwrap_err().to_string();
436        assert!(err.contains("symlink"), "got: {err}");
437
438        // And the tool itself refuses rather than returning the file.
439        let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
440        assert!(out.contains("[error]"), "got: {out}");
441    }
442
443    /// A write through an escaping symlink is refused too - this was the path
444    /// that could overwrite `~/.ssh/authorized_keys`.
445    #[cfg(unix)]
446    #[tokio::test]
447    async fn write_file_rejects_symlink_escape() {
448        let dir = tempfile::tempdir().unwrap();
449        let outside = tempfile::tempdir().unwrap();
450        let workdir = dir.path().join("workspace");
451        fs::create_dir(&workdir).unwrap();
452        std::os::unix::fs::symlink(outside.path(), workdir.join("link")).unwrap();
453        let tools = make_tools(&workdir);
454
455        let out = tools
456            .write_file(&json!({ "path": "link/pwned.txt", "content": "x" }))
457            .await;
458        assert!(out.contains("[error]"), "got: {out}");
459        assert!(
460            !outside.path().join("pwned.txt").exists(),
461            "nothing may be written outside the workdir"
462        );
463    }
464
465    // ── [read_paths]: reads may be granted outside the workdir ────────────
466
467    /// Tools whose context carries a `[read_paths]` policy compiled for
468    /// `workdir` (no home, unix path semantics - the platform seams have
469    /// their own tests in `leviath_core::read_paths`).
470    fn make_tools_with_read_paths(
471        workdir: &std::path::Path,
472        blueprint: &[&str],
473        grants: &[&str],
474        allow_blueprint: bool,
475    ) -> BuiltinTools {
476        let compile = |entries: &[&str]| {
477            let raw: Vec<String> = entries.iter().map(|s| s.to_string()).collect();
478            leviath_core::ReadPathSet::compile(&raw, workdir, None, false)
479                .expect("test entries compile")
480        };
481        let policy = leviath_core::ReadPathPolicy {
482            agent: "tester".into(),
483            blueprint: compile(blueprint),
484            grants: compile(grants),
485            allow_blueprint,
486        };
487        BuiltinTools::new(ToolContext::new(workdir.to_path_buf()).with_read_paths(policy))
488    }
489
490    /// The whole point of the feature: a declared-and-granted directory is
491    /// readable, through every read-only tool.
492    #[tokio::test]
493    async fn read_tools_reach_a_declared_and_granted_outside_path() {
494        let dir = tempfile::tempdir().unwrap();
495        let outside = tempfile::tempdir().unwrap();
496        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
497        let entry = outside.path().to_str().unwrap();
498        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
499
500        let target = outside.path().join("doc.md");
501        let target = target.to_str().unwrap();
502        let out = tools.read_file(&json!({ "path": target })).await;
503        assert_eq!(out, "outside contents");
504
505        let listed = tools
506            .list_dir(&json!({ "path": outside.path().to_str().unwrap() }))
507            .await;
508        assert!(listed.contains("doc.md"), "got: {listed}");
509
510        // `read_files` mixes inside and outside paths per element.
511        fs::write(dir.path().join("inside.txt"), "inside contents").unwrap();
512        let out = tools
513            .read_files(&json!({ "paths": ["inside.txt", target] }))
514            .await;
515        assert!(out.contains("inside contents"), "got: {out}");
516        assert!(out.contains("outside contents"), "got: {out}");
517    }
518
519    /// `[read_paths]` grants reads and nothing else: the same fully granted
520    /// path is still refused for `write_file` and `edit_file`, which never
521    /// consult the policy.
522    #[tokio::test]
523    async fn write_and_edit_stay_confined_despite_read_grants() {
524        let dir = tempfile::tempdir().unwrap();
525        let outside = tempfile::tempdir().unwrap();
526        fs::write(outside.path().join("doc.md"), "original").unwrap();
527        let entry = outside.path().to_str().unwrap();
528        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
529
530        let target = outside.path().join("doc.md");
531        let target = target.to_str().unwrap();
532        let out = tools
533            .write_file(&json!({ "path": target, "content": "clobbered" }))
534            .await;
535        assert!(out.contains("[error]"), "got: {out}");
536        let out = tools
537            .edit_file(&json!({ "path": target, "old_str": "original", "new_str": "x" }))
538            .await;
539        assert!(out.contains("[error]"), "got: {out}");
540        assert_eq!(
541            fs::read_to_string(outside.path().join("doc.md")).unwrap(),
542            "original",
543            "a read grant must never permit a write"
544        );
545    }
546
547    /// Declared by the blueprint but granted by nothing: refused, and the
548    /// error says exactly which config stanza would grant it.
549    #[tokio::test]
550    async fn an_ungranted_declaration_is_refused_with_guidance() {
551        let dir = tempfile::tempdir().unwrap();
552        let outside = tempfile::tempdir().unwrap();
553        fs::write(outside.path().join("doc.md"), "secret").unwrap();
554        let entry = outside.path().to_str().unwrap();
555        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], false);
556
557        let target = outside.path().join("doc.md");
558        let out = tools
559            .read_file(&json!({ "path": target.to_str().unwrap() }))
560            .await;
561        assert!(out.contains("[error]"), "got: {out}");
562        assert!(out.contains("does not grant"), "got: {out}");
563        assert!(out.contains("[agent_read_paths.tester]"), "got: {out}");
564        assert!(!out.contains("secret"), "content must not leak");
565    }
566
567    /// The `allow_blueprint_read_paths` override honors declarations without
568    /// itemized grants - and still nothing beyond what is declared.
569    #[tokio::test]
570    async fn the_blanket_override_honors_declarations() {
571        let dir = tempfile::tempdir().unwrap();
572        let outside = tempfile::tempdir().unwrap();
573        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
574        let entry = outside.path().to_str().unwrap();
575        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], true);
576
577        let target = outside.path().join("doc.md");
578        let out = tools
579            .read_file(&json!({ "path": target.to_str().unwrap() }))
580            .await;
581        assert_eq!(out, "outside contents");
582
583        // Undeclared stays undeclared: the override widens nothing.
584        let undeclared = tempfile::tempdir().unwrap();
585        fs::write(undeclared.path().join("x.txt"), "x").unwrap();
586        let out = tools
587            .read_file(&json!({ "path": undeclared.path().join("x.txt").to_str().unwrap() }))
588            .await;
589        assert!(
590            out.contains("not in this agent's [read_paths]"),
591            "got: {out}"
592        );
593    }
594
595    /// With no `[read_paths]` at all, an outside read gets the original
596    /// workdir refusal, word for word - the policy is never consulted.
597    #[tokio::test]
598    async fn an_inactive_policy_keeps_the_workdir_error() {
599        let dir = tempfile::tempdir().unwrap();
600        let tools = make_tools(dir.path());
601        let out = tools.read_file(&json!({ "path": "/etc/hosts" })).await;
602        assert!(
603            out.contains("would escape the working directory"),
604            "got: {out}"
605        );
606    }
607
608    /// A relative request resolves against the workdir in the fallback too,
609    /// so a workdir-relative entry like `../shared` is reachable by the
610    /// matching relative request.
611    #[tokio::test]
612    async fn a_relative_request_reaches_a_relative_grant() {
613        let parent = tempfile::tempdir().unwrap();
614        let workdir = parent.path().join("work");
615        let shared = parent.path().join("shared");
616        fs::create_dir_all(&workdir).unwrap();
617        fs::create_dir_all(&shared).unwrap();
618        fs::write(shared.join("doc.md"), "shared contents").unwrap();
619        let tools = make_tools_with_read_paths(&workdir, &["../shared"], &["../shared"], false);
620
621        let out = tools
622            .read_file(&json!({ "path": "../shared/doc.md" }))
623            .await;
624        assert_eq!(out, "shared contents");
625    }
626
627    /// An interior `.` in a fallback request is folded away (`Path::components`
628    /// drops it), so `<granted>/./doc.md` resolves the same as
629    /// `<granted>/doc.md`.
630    #[tokio::test]
631    async fn a_dot_component_is_folded_in_the_fallback() {
632        let dir = tempfile::tempdir().unwrap();
633        let outside = tempfile::tempdir().unwrap();
634        fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
635        let entry = outside.path().to_str().unwrap();
636        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
637
638        let target = format!("{}/./doc.md", outside.path().to_str().unwrap());
639        let out = tools.read_file(&json!({ "path": target })).await;
640        assert_eq!(out, "outside contents");
641    }
642
643    /// Folding `..` past the top is unresolvable no matter what any allowlist
644    /// says. Mirrors `resolve_rejects_excessive_parent_dir_traversal`: a
645    /// *relative* base (`wd`) gives the accumulator exactly one leading
646    /// `Normal` component and no platform-specific root/drive/prefix, so the
647    /// first `..` pops `wd` and the second calls `pop()` on an empty
648    /// accumulator - firing the bail on every OS. `/..` or an empty base does
649    /// not: neither is absolute on Windows, and the join reshapes them so the
650    /// `pop()` never fails there.
651    #[test]
652    fn folding_past_the_root_is_unresolvable() {
653        let policy = leviath_core::ReadPathPolicy {
654            agent: "tester".into(),
655            allow_blueprint: true,
656            ..Default::default()
657        };
658        let err = BuiltinTools::resolve_outside(
659            "../../x",
660            Path::new("wd"),
661            &policy,
662            leviath_core::canonicalize_for_match,
663        )
664        .expect_err("popping past the top must be refused");
665        assert!(err.to_string().contains("cannot be resolved"), "{err}");
666    }
667
668    /// The fail-closed arm, driven through the injected canonicalizer so it
669    /// runs on every platform: a path nothing can verify is refused, never
670    /// matched.
671    #[test]
672    fn an_unverifiable_path_is_refused() {
673        fn unverifiable(_: &Path) -> Option<PathBuf> {
674            None
675        }
676        let policy = leviath_core::ReadPathPolicy {
677            agent: "tester".into(),
678            allow_blueprint: true,
679            ..Default::default()
680        };
681        let err =
682            BuiltinTools::resolve_outside("/outside/x", Path::new("/w"), &policy, unverifiable)
683                .expect_err("an unverifiable path must be refused");
684        assert!(err.to_string().contains("cannot be verified"), "{err}");
685    }
686
687    /// The attack the policy exists to stop: a symlink planted *inside* a
688    /// granted directory, pointing outside it. The policy sees the real
689    /// target, which no entry declares.
690    #[cfg(unix)]
691    #[tokio::test]
692    async fn a_symlink_inside_a_granted_directory_cannot_escape_it() {
693        let dir = tempfile::tempdir().unwrap();
694        let granted = tempfile::tempdir().unwrap();
695        let secret_home = tempfile::tempdir().unwrap();
696        fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
697        std::os::unix::fs::symlink(
698            secret_home.path().join("id_rsa"),
699            granted.path().join("innocent.md"),
700        )
701        .unwrap();
702        let entry = granted.path().to_str().unwrap();
703        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
704
705        let out = tools
706            .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
707            .await;
708        assert!(out.contains("[error]"), "got: {out}");
709        assert!(!out.contains("PRIVATE KEY"), "content must not leak");
710    }
711
712    /// The same attack against a glob entry - the variant the original PR
713    /// missed entirely. The pattern is matched against the symlink-resolved
714    /// real path, and the real target does not match it.
715    #[cfg(unix)]
716    #[tokio::test]
717    async fn a_glob_grant_is_symlink_safe() {
718        let dir = tempfile::tempdir().unwrap();
719        let granted = tempfile::tempdir().unwrap();
720        let secret_home = tempfile::tempdir().unwrap();
721        fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
722        std::os::unix::fs::symlink(
723            secret_home.path().join("id_rsa"),
724            granted.path().join("innocent.md"),
725        )
726        .unwrap();
727        // Patterns match the canonical real path, so build the entry from it.
728        let canonical = fs::canonicalize(granted.path()).unwrap();
729        let entry = format!("glob:{}/**", canonical.display());
730        let tools = make_tools_with_read_paths(dir.path(), &[&entry], &[&entry], false);
731
732        let out = tools
733            .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
734            .await;
735        assert!(out.contains("[error]"), "got: {out}");
736        assert!(!out.contains("PRIVATE KEY"), "content must not leak");
737
738        // The positive pair: a real file under the same glob is readable, so
739        // the refusal above is the symlink and not the pattern.
740        fs::write(granted.path().join("real.md"), "real contents").unwrap();
741        let out = tools
742            .read_file(&json!({ "path": granted.path().join("real.md").to_str().unwrap() }))
743            .await;
744        assert_eq!(out, "real contents");
745    }
746
747    /// A symlink whose target stays inside the granted subtree is fine - the
748    /// rule is about where the path lands, exactly as in the workdir.
749    #[cfg(unix)]
750    #[tokio::test]
751    async fn a_symlink_within_a_granted_directory_is_readable() {
752        let dir = tempfile::tempdir().unwrap();
753        let granted = tempfile::tempdir().unwrap();
754        fs::create_dir(granted.path().join("real")).unwrap();
755        fs::write(granted.path().join("real/doc.md"), "granted contents").unwrap();
756        std::os::unix::fs::symlink(granted.path().join("real"), granted.path().join("link"))
757            .unwrap();
758        let entry = granted.path().to_str().unwrap();
759        let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
760
761        let out = tools
762            .read_file(&json!({ "path": granted.path().join("link/doc.md").to_str().unwrap() }))
763            .await;
764        assert_eq!(out, "granted contents");
765    }
766
767    /// A symlink that stays *inside* the workdir keeps working - the rule is
768    /// about where the path lands, not whether a symlink was involved. Agents
769    /// operate on real repositories, which contain plenty of internal symlinks.
770    #[cfg(unix)]
771    #[tokio::test]
772    async fn resolve_allows_symlink_within_workdir() {
773        let dir = tempfile::tempdir().unwrap();
774        let workdir = dir.path().join("workspace");
775        fs::create_dir(&workdir).unwrap();
776        fs::create_dir(workdir.join("real")).unwrap();
777        fs::write(workdir.join("real/file.txt"), "contents").unwrap();
778        std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")).unwrap();
779        let tools = make_tools(&workdir);
780
781        assert!(tools.resolve("link/file.txt").is_ok());
782        let out = tools.read_file(&json!({ "path": "link/file.txt" })).await;
783        assert_eq!(out, "contents");
784    }
785
786    #[test]
787    fn resolve_dot_stays_in_workdir() {
788        let dir = std::env::temp_dir();
789        let tools = make_tools(&dir);
790        let result = tools.resolve("./foo/./bar.txt").unwrap();
791        assert!(result.starts_with(&tools.ctx.workdir));
792        assert!(result.ends_with("foo/bar.txt"));
793    }
794
795    // ── execute() with file I/O (async) ───────────────────────────────────
796
797    #[tokio::test]
798    async fn execute_unknown_tool_returns_error() {
799        let dir = std::env::temp_dir();
800        let tools = make_tools(&dir);
801        let result = tools.execute("nonexistent", json!({})).await;
802        assert!(result.contains("[error]"));
803        assert!(result.contains("Unknown built-in tool"));
804    }
805
806    #[tokio::test]
807    async fn read_file_missing_path_arg() {
808        let dir = std::env::temp_dir();
809        let tools = make_tools(&dir);
810        let result = tools.execute("read_file", json!({})).await;
811        assert!(result.contains("[error]"));
812        assert!(result.contains("missing 'path'"));
813    }
814
815    #[tokio::test]
816    async fn write_and_read_file_roundtrip() {
817        let dir = tempfile::tempdir().unwrap();
818        let tools = make_tools(dir.path());
819
820        let write_result = tools
821            .execute(
822                "write_file",
823                json!({"path": "test.txt", "content": "hello world"}),
824            )
825            .await;
826        assert!(write_result.contains("Successfully wrote"));
827        assert!(write_result.contains("11 bytes"));
828
829        let read_result = tools
830            .execute("read_file", json!({"path": "test.txt"}))
831            .await;
832        assert_eq!(read_result, "hello world");
833    }
834
835    #[tokio::test]
836    async fn write_file_creates_parent_dirs() {
837        let dir = tempfile::tempdir().unwrap();
838        let tools = make_tools(dir.path());
839
840        let result = tools
841            .execute(
842                "write_file",
843                json!({"path": "sub/dir/file.txt", "content": "nested"}),
844            )
845            .await;
846        assert!(result.contains("Successfully wrote"));
847        assert!(dir.path().join("sub/dir/file.txt").exists());
848    }
849
850    #[tokio::test]
851    async fn write_tools_refuse_to_resurrect_a_deleted_workspace() {
852        // Issue #107: an external harness deletes the workspace mid-run.
853        // `create_dir_all` would happily recreate it and let the agent write
854        // into an empty tree that no longer resembles the checkout it reasoned
855        // about - and the runtime's health check, which just stats the workdir,
856        // would never see it was gone.
857        let dir = tempfile::tempdir().unwrap();
858        let workdir = dir.path().join("workspace");
859        fs::create_dir(&workdir).unwrap();
860        fs::write(workdir.join("a.txt"), "before").unwrap();
861        let tools = make_tools(&workdir);
862        fs::remove_dir_all(&workdir).unwrap();
863
864        for (tool, args) in [
865            ("write_file", json!({"path": "a.txt", "content": "after"})),
866            (
867                "edit_file",
868                json!({"path": "a.txt", "old_str": "before", "new_str": "after"}),
869            ),
870        ] {
871            let result = tools.execute(tool, args).await;
872            assert!(
873                result.contains("workspace") && result.contains("no longer accessible"),
874                "{tool} got: {result}"
875            );
876        }
877        assert!(!workdir.exists(), "the workspace must stay gone");
878    }
879
880    #[tokio::test]
881    async fn write_file_missing_content_arg() {
882        let dir = tempfile::tempdir().unwrap();
883        let tools = make_tools(dir.path());
884        let result = tools.execute("write_file", json!({"path": "f.txt"})).await;
885        assert!(result.contains("missing 'content'"));
886    }
887
888    #[tokio::test]
889    async fn write_file_missing_path_arg() {
890        let dir = tempfile::tempdir().unwrap();
891        let tools = make_tools(dir.path());
892        let result = tools.execute("write_file", json!({"content": "x"})).await;
893        assert!(result.contains("missing 'path'"));
894    }
895
896    #[test]
897    fn resolve_rejects_excessive_parent_dir_traversal() {
898        // A *relative, nonexistent* workdir keeps `resolve`'s accumulator free
899        // of any platform-specific leading root/drive/prefix components:
900        // `canonicalize` fails for a path that doesn't exist (on every OS), so
901        // `ToolContext::new` keeps the raw relative `PathBuf` verbatim. The
902        // request then decomposes into exactly `[Normal(workdir), ParentDir,
903        // ParentDir, ...]`; the first `..` pops the single workdir component and
904        // the second `..` calls `normalized.pop()` on an *empty* accumulator,
905        // which returns `false` - firing the "escapes the working directory"
906        // bail deterministically on every OS.
907        //
908        // (An empty "" workdir is not portable here: on Windows `canonicalize("")`
909        // can succeed and yield an absolute cwd whose Prefix/RootDir components
910        // absorb the `..`, so `pop()` never fails and this bail is never hit --
911        // which is exactly why this branch was Windows-uncovered before.)
912        let tools = BuiltinTools::new(ToolContext::new(PathBuf::from(
913            "leviath-nonexistent-relative-workdir",
914        )));
915        let result = tools.resolve("../../etc/passwd");
916        assert!(result.is_err());
917        assert!(
918            result
919                .unwrap_err()
920                .to_string()
921                .contains("escapes the working directory")
922        );
923    }
924
925    #[tokio::test]
926    async fn edit_file_successful_replacement() {
927        let dir = tempfile::tempdir().unwrap();
928        let tools = make_tools(dir.path());
929
930        tools
931            .execute(
932                "write_file",
933                json!({"path": "e.txt", "content": "foo bar baz"}),
934            )
935            .await;
936
937        let result = tools
938            .execute(
939                "edit_file",
940                json!({"path": "e.txt", "old_str": "bar", "new_str": "qux"}),
941            )
942            .await;
943        assert!(result.contains("Successfully edited"));
944
945        let content = tools.execute("read_file", json!({"path": "e.txt"})).await;
946        assert_eq!(content, "foo qux baz");
947    }
948
949    #[tokio::test]
950    async fn edit_file_string_not_found() {
951        let dir = tempfile::tempdir().unwrap();
952        let tools = make_tools(dir.path());
953
954        tools
955            .execute("write_file", json!({"path": "e.txt", "content": "abc"}))
956            .await;
957
958        let result = tools
959            .execute(
960                "edit_file",
961                json!({"path": "e.txt", "old_str": "xyz", "new_str": "123"}),
962            )
963            .await;
964        assert!(result.contains("String not found"));
965    }
966
967    #[tokio::test]
968    async fn edit_file_missing_file_returns_read_error() {
969        let dir = tempfile::tempdir().unwrap();
970        let tools = make_tools(dir.path());
971
972        let result = tools
973            .execute(
974                "edit_file",
975                json!({"path": "does-not-exist.txt", "old_str": "a", "new_str": "b"}),
976            )
977            .await;
978        assert!(result.contains("[error]"));
979        assert!(result.contains("Failed to read"));
980    }
981
982    #[tokio::test]
983    async fn edit_file_multiple_occurrences() {
984        let dir = tempfile::tempdir().unwrap();
985        let tools = make_tools(dir.path());
986
987        tools
988            .execute("write_file", json!({"path": "e.txt", "content": "aaa aaa"}))
989            .await;
990
991        let result = tools
992            .execute(
993                "edit_file",
994                json!({"path": "e.txt", "old_str": "aaa", "new_str": "bbb"}),
995            )
996            .await;
997        assert!(result.contains("2 occurrences"));
998        assert!(result.contains("must be unique"));
999    }
1000
1001    #[tokio::test]
1002    async fn edit_file_missing_args() {
1003        let dir = tempfile::tempdir().unwrap();
1004        let tools = make_tools(dir.path());
1005
1006        let r1 = tools.execute("edit_file", json!({})).await;
1007        assert!(r1.contains("missing 'path'"));
1008
1009        let r2 = tools.execute("edit_file", json!({"path": "f.txt"})).await;
1010        assert!(r2.contains("missing 'old_str'"));
1011
1012        let r3 = tools
1013            .execute("edit_file", json!({"path": "f.txt", "old_str": "x"}))
1014            .await;
1015        assert!(r3.contains("missing 'new_str'"));
1016    }
1017
1018    #[tokio::test]
1019    async fn list_dir_contents() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let tools = make_tools(dir.path());
1022
1023        fs::write(dir.path().join("a.txt"), "hello").unwrap();
1024        fs::create_dir(dir.path().join("subdir")).unwrap();
1025
1026        let result = tools.execute("list_dir", json!({})).await;
1027        assert!(result.contains("a.txt"));
1028        assert!(result.contains("subdir/"));
1029    }
1030
1031    #[tokio::test]
1032    async fn list_dir_empty() {
1033        let dir = tempfile::tempdir().unwrap();
1034        let tools = make_tools(dir.path());
1035        let result = tools.execute("list_dir", json!({})).await;
1036        assert!(result.contains("empty directory"));
1037    }
1038
1039    #[tokio::test]
1040    async fn list_dir_with_path() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let tools = make_tools(dir.path());
1043
1044        fs::create_dir(dir.path().join("sub")).unwrap();
1045        fs::write(dir.path().join("sub/inner.txt"), "data").unwrap();
1046
1047        let result = tools.execute("list_dir", json!({"path": "sub"})).await;
1048        assert!(result.contains("inner.txt"));
1049    }
1050
1051    #[tokio::test]
1052    async fn read_file_nonexistent() {
1053        let dir = tempfile::tempdir().unwrap();
1054        let tools = make_tools(dir.path());
1055        let result = tools
1056            .execute("read_file", json!({"path": "nope.txt"}))
1057            .await;
1058        assert!(result.contains("[error]"));
1059        assert!(result.contains("Failed to read"));
1060    }
1061
1062    // ── read_files (batch reads) ────────────────────────────────────────────
1063
1064    #[tokio::test]
1065    async fn read_files_multiple_valid_files() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let tools = make_tools(dir.path());
1068        fs::write(dir.path().join("a.txt"), "alpha").unwrap();
1069        fs::write(dir.path().join("b.txt"), "beta").unwrap();
1070
1071        let result = tools
1072            .execute("read_files", json!({"paths": ["a.txt", "b.txt"]}))
1073            .await;
1074        assert!(result.contains("### [a.txt]"));
1075        assert!(result.contains("alpha"));
1076        assert!(result.contains("### [b.txt]"));
1077        assert!(result.contains("beta"));
1078        // Results are joined with a blank line between entries.
1079        assert!(result.contains("\n\n"));
1080    }
1081
1082    #[tokio::test]
1083    async fn read_files_missing_paths_arg() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let tools = make_tools(dir.path());
1086        let result = tools.execute("read_files", json!({})).await;
1087        assert!(result.contains("[error]"));
1088        assert!(result.contains("missing 'paths'"));
1089    }
1090
1091    #[tokio::test]
1092    async fn read_files_non_array_paths_arg() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let tools = make_tools(dir.path());
1095        // A string (not an array) → as_array() returns None → same error path.
1096        let result = tools.execute("read_files", json!({"paths": "a.txt"})).await;
1097        assert!(result.contains("[error]"));
1098        assert!(result.contains("missing 'paths'"));
1099    }
1100
1101    #[tokio::test]
1102    async fn read_files_empty_paths_array() {
1103        let dir = tempfile::tempdir().unwrap();
1104        let tools = make_tools(dir.path());
1105        let result = tools.execute("read_files", json!({"paths": []})).await;
1106        assert!(result.contains("[error]"));
1107        assert!(result.contains("empty"));
1108    }
1109
1110    #[tokio::test]
1111    async fn read_files_missing_file_reports_per_file_error() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let tools = make_tools(dir.path());
1114        fs::write(dir.path().join("present.txt"), "here").unwrap();
1115
1116        let result = tools
1117            .execute(
1118                "read_files",
1119                json!({"paths": ["present.txt", "absent.txt"]}),
1120            )
1121            .await;
1122        // Valid file still returned…
1123        assert!(result.contains("### [present.txt]"));
1124        assert!(result.contains("here"));
1125        // …while the missing one produces a per-file error under its header.
1126        assert!(result.contains("### [absent.txt]"));
1127        assert!(result.contains("Failed to read"));
1128    }
1129
1130    #[tokio::test]
1131    async fn read_files_non_string_element_reports_error() {
1132        let dir = tempfile::tempdir().unwrap();
1133        let tools = make_tools(dir.path());
1134        fs::write(dir.path().join("ok.txt"), "content").unwrap();
1135
1136        let result = tools
1137            .execute("read_files", json!({"paths": ["ok.txt", 42]}))
1138            .await;
1139        assert!(result.contains("content"));
1140        assert!(result.contains("non-string path in array"));
1141    }
1142
1143    #[tokio::test]
1144    async fn read_files_path_escape_reported_per_file() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let tools = make_tools(dir.path());
1147        let result = tools
1148            .execute("read_files", json!({"paths": ["../../etc/passwd"]}))
1149            .await;
1150        assert!(result.contains("### [../../etc/passwd]"));
1151        assert!(result.contains("escape"));
1152    }
1153
1154    // ── resolve() absolute paths ────────────────────────────────────────────
1155
1156    #[test]
1157    fn resolve_absolute_path_inside_workdir() {
1158        let dir = tempfile::tempdir().unwrap();
1159        let tools = make_tools(dir.path());
1160        // Build the absolute path from the tool's own (canonicalized) workdir
1161        // rather than `dir.path()` directly - on macOS `/tmp`/`/var` are
1162        // symlinks, so the two can differ even though they're the same place.
1163        let abs = tools.ctx.workdir.join("inside.txt");
1164        let result = tools.resolve(abs.to_str().unwrap()).unwrap();
1165        assert_eq!(result, abs);
1166    }
1167
1168    #[test]
1169    fn resolve_rejects_absolute_path_outside_workdir() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let tools = make_tools(dir.path());
1172        let result = tools.resolve("/etc/passwd");
1173        assert!(result.is_err());
1174    }
1175
1176    // ── path-escape rejection propagates through each tool ─────────────────
1177
1178    #[tokio::test]
1179    async fn read_file_path_escape_rejected() {
1180        let dir = tempfile::tempdir().unwrap();
1181        let tools = make_tools(dir.path());
1182        let result = tools
1183            .execute("read_file", json!({"path": "../../etc/passwd"}))
1184            .await;
1185        assert!(result.contains("[error]"));
1186        assert!(result.contains("escape"));
1187    }
1188
1189    #[tokio::test]
1190    async fn write_file_path_escape_rejected() {
1191        let dir = tempfile::tempdir().unwrap();
1192        let tools = make_tools(dir.path());
1193        let result = tools
1194            .execute(
1195                "write_file",
1196                json!({"path": "../../evil.txt", "content": "x"}),
1197            )
1198            .await;
1199        assert!(result.contains("[error]"));
1200        assert!(result.contains("escape"));
1201    }
1202
1203    #[tokio::test]
1204    async fn edit_file_path_escape_rejected() {
1205        let dir = tempfile::tempdir().unwrap();
1206        let tools = make_tools(dir.path());
1207        let result = tools
1208            .execute(
1209                "edit_file",
1210                json!({"path": "../../evil.txt", "old_str": "a", "new_str": "b"}),
1211            )
1212            .await;
1213        assert!(result.contains("[error]"));
1214        assert!(result.contains("escape"));
1215    }
1216
1217    #[tokio::test]
1218    async fn list_dir_path_escape_rejected() {
1219        let dir = tempfile::tempdir().unwrap();
1220        let tools = make_tools(dir.path());
1221        let result = tools.execute("list_dir", json!({"path": "../../"})).await;
1222        assert!(result.contains("[error]"));
1223        assert!(result.contains("escape"));
1224    }
1225
1226    // ── filesystem failure branches ─────────────────────────────────────────
1227
1228    #[tokio::test]
1229    async fn write_file_fails_when_path_is_a_directory() {
1230        let dir = tempfile::tempdir().unwrap();
1231        let tools = make_tools(dir.path());
1232        fs::create_dir(dir.path().join("adir")).unwrap();
1233
1234        let result = tools
1235            .execute("write_file", json!({"path": "adir", "content": "x"}))
1236            .await;
1237        assert!(result.contains("[error]"));
1238        assert!(result.contains("Failed to write"));
1239    }
1240
1241    #[tokio::test]
1242    async fn write_file_parent_dir_creation_fails_when_blocked_by_file() {
1243        let dir = tempfile::tempdir().unwrap();
1244        let tools = make_tools(dir.path());
1245        // "blocker" exists as a plain file, so create_dir_all("blocker") must fail.
1246        fs::write(dir.path().join("blocker"), "im a file").unwrap();
1247
1248        let result = tools
1249            .execute(
1250                "write_file",
1251                json!({"path": "blocker/nested.txt", "content": "x"}),
1252            )
1253            .await;
1254        assert!(result.contains("[error]"));
1255        assert!(result.contains("Failed to create directories"));
1256    }
1257
1258    #[tokio::test]
1259    async fn read_file_fails_when_path_is_a_directory() {
1260        let dir = tempfile::tempdir().unwrap();
1261        let tools = make_tools(dir.path());
1262        fs::create_dir(dir.path().join("adir")).unwrap();
1263
1264        let result = tools.execute("read_file", json!({"path": "adir"})).await;
1265        assert!(result.contains("[error]"));
1266        assert!(result.contains("Failed to read"));
1267    }
1268
1269    #[tokio::test]
1270    async fn list_dir_fails_when_path_is_a_file() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let tools = make_tools(dir.path());
1273        fs::write(dir.path().join("afile.txt"), "content").unwrap();
1274
1275        let result = tools
1276            .execute("list_dir", json!({"path": "afile.txt"}))
1277            .await;
1278        assert!(result.contains("[error]"));
1279        assert!(result.contains("Failed to read directory"));
1280    }
1281
1282    // `set_readonly(false)` widens Unix perms beyond the original, but here it
1283    // only re-enables cleanup of a throwaway tempdir file, which is exactly
1284    // what we want.
1285    #[allow(clippy::permissions_set_readonly_false)]
1286    #[tokio::test]
1287    async fn edit_file_write_failure_after_successful_match() {
1288        let dir = tempfile::tempdir().unwrap();
1289        let tools = make_tools(dir.path());
1290        let file_path = dir.path().join("ro.txt");
1291        fs::write(&file_path, "hello world").unwrap();
1292
1293        // Make the file read-only so the read succeeds but the write-back
1294        // fails. `set_readonly(true)` is cross-platform (clears the write bits
1295        // on Unix; sets the read-only attribute on Windows), so the write
1296        // error arm is exercised on every OS.
1297        let mut perms = fs::metadata(&file_path).unwrap().permissions();
1298        perms.set_readonly(true);
1299        fs::set_permissions(&file_path, perms).unwrap();
1300
1301        let result = tools
1302            .execute(
1303                "edit_file",
1304                json!({"path": "ro.txt", "old_str": "hello", "new_str": "goodbye"}),
1305            )
1306            .await;
1307
1308        // Restore permissions so tempdir cleanup can remove the file.
1309        let mut perms = fs::metadata(&file_path).unwrap().permissions();
1310        perms.set_readonly(false);
1311        fs::set_permissions(&file_path, perms).unwrap();
1312
1313        assert!(result.contains("[error]"));
1314        assert!(result.contains("Failed to write"));
1315    }
1316
1317    #[tokio::test]
1318    async fn shell_echo_command() {
1319        let dir = tempfile::tempdir().unwrap();
1320        let tools = make_tools(dir.path());
1321        let result = tools
1322            .execute("shell", json!({"command": "echo hello"}))
1323            .await;
1324        assert!(result.trim().contains("hello"));
1325    }
1326
1327    #[tokio::test]
1328    async fn bash_alias_works() {
1329        let dir = tempfile::tempdir().unwrap();
1330        let tools = make_tools(dir.path());
1331        let result = tools
1332            .execute("bash", json!({"command": "echo alias_test"}))
1333            .await;
1334        assert!(result.contains("alias_test"));
1335    }
1336
1337    #[tokio::test]
1338    async fn shell_missing_command_arg() {
1339        let dir = tempfile::tempdir().unwrap();
1340        let tools = make_tools(dir.path());
1341        let result = tools.execute("shell", json!({})).await;
1342        assert!(result.contains("missing 'command'"));
1343    }
1344
1345    /// A `ShellExecutor` that ignores the requested command and instead runs a
1346    /// fixed marker command - proof that shell execution is routed through it.
1347    struct RedirectExecutor;
1348    impl ShellExecutor for RedirectExecutor {
1349        fn build_command(
1350            &self,
1351            shell: &str,
1352            flag: &str,
1353            _command: &str,
1354            workdir: &Path,
1355        ) -> Command {
1356            let mut c = Command::new(shell);
1357            c.arg(flag).arg("echo SANDBOXED").current_dir(workdir);
1358            c
1359        }
1360    }
1361
1362    #[tokio::test]
1363    async fn shell_routes_through_executor_when_present() {
1364        let dir = tempfile::tempdir().unwrap();
1365        let tools = BuiltinTools::new(ToolContext::new(dir.path().to_path_buf()))
1366            .with_shell_executor(Arc::new(RedirectExecutor));
1367        // The agent asked for `echo host`, but the executor redirects it.
1368        let result = tools
1369            .execute("shell", json!({"command": "echo host"}))
1370            .await;
1371        assert!(result.contains("SANDBOXED"), "got: {result}");
1372        assert!(!result.contains("host"));
1373    }
1374
1375    #[tokio::test]
1376    async fn shell_failing_command() {
1377        let dir = tempfile::tempdir().unwrap();
1378        let tools = make_tools(dir.path());
1379        let result = tools.execute("shell", json!({"command": "false"})).await;
1380        assert!(result.contains("[exit code"));
1381    }
1382
1383    #[tokio::test]
1384    async fn shell_successful_command_with_no_output() {
1385        let dir = tempfile::tempdir().unwrap();
1386        let tools = make_tools(dir.path());
1387        let result = tools.execute("shell", json!({"command": "true"})).await;
1388        assert_eq!(result, "(command succeeded with no output)");
1389    }
1390
1391    // The stdout+stderr non-zero-exit formatting is asserted directly against
1392    // `format_command_output` (below) rather than via a real shell command:
1393    // producing stdout, stderr, and a non-zero exit in a single command needs
1394    // shell-specific syntax (`;`/`1>&2` on `sh`, `&`/redirection on `cmd.exe`)
1395    // that isn't portable, and this session already hit real Windows CI
1396    // failures from insufficiently-verified platform-specific test commands.
1397    #[test]
1398    fn format_command_output_non_zero_exit_reports_stdout_and_stderr() {
1399        let result = BuiltinTools::format_command_output(b"out-line\n", b"err-line\n", false, 1);
1400        assert!(result.contains("[exit code 1]"));
1401        assert!(result.contains("stdout:"));
1402        assert!(result.contains("out-line"));
1403        assert!(result.contains("stderr:"));
1404        assert!(result.contains("err-line"));
1405    }
1406
1407    #[test]
1408    fn format_command_output_non_zero_exit_omits_empty_streams() {
1409        // Whitespace-only streams are treated as empty and neither the
1410        // stdout: nor stderr: block is emitted.
1411        let result = BuiltinTools::format_command_output(b"   \n", b"", false, 2);
1412        assert_eq!(result, "[exit code 2]\n");
1413    }
1414
1415    #[test]
1416    fn format_command_output_success_with_output_returns_stdout() {
1417        let result = BuiltinTools::format_command_output(b"hello\n", b"", true, 0);
1418        assert_eq!(result, "hello\n");
1419    }
1420
1421    #[test]
1422    fn format_command_output_success_no_output() {
1423        let result = BuiltinTools::format_command_output(b"   ", b"noise", true, 0);
1424        assert_eq!(result, "(command succeeded with no output)");
1425    }
1426
1427    #[tokio::test]
1428    async fn shell_with_timeout_fires_on_slow_command() {
1429        let dir = tempfile::tempdir().unwrap();
1430        let tools = make_tools(dir.path());
1431        let result = tools
1432            .shell_with_timeout(&json!({"command": "sleep 5"}), Duration::from_millis(100))
1433            .await;
1434        assert!(result.contains("[timed out]"));
1435    }
1436
1437    /// A timed-out (or cancelled) command takes its *grandchildren* with it.
1438    ///
1439    /// `kill_on_drop` only reaps the shell. Anything the shell started is
1440    /// reparented to init and keeps running - a cancelled agent's `sleep`
1441    /// outliving the run that spawned it. Verified by writing a marker file
1442    /// after a delay: if the grandchild survived, the marker appears.
1443    #[cfg(unix)]
1444    #[tokio::test]
1445    async fn a_timed_out_command_kills_its_grandchildren() {
1446        let dir = tempfile::tempdir().unwrap();
1447        let marker = dir.path().join("survived");
1448        let tools = make_tools(dir.path());
1449
1450        // A *backgrounded subshell* is the grandchild, and it is what writes the
1451        // marker. Chaining (`sleep 2 && touch`) would not test anything: the
1452        // `touch` is run by the shell itself, so killing the shell suppresses it
1453        // whether or not the group was signalled.
1454        let cmd = format!("( sleep 2; touch {} ) & sleep 30", marker.display());
1455        let result = tools
1456            .shell_with_timeout(&json!({ "command": cmd }), Duration::from_millis(100))
1457            .await;
1458        assert!(result.contains("[timed out]"), "got: {result}");
1459
1460        // Well past when the grandchild would have written it.
1461        tokio::time::sleep(Duration::from_secs(3)).await;
1462        assert!(
1463            !marker.exists(),
1464            "the grandchild outlived the command that started it"
1465        );
1466    }
1467
1468    #[tokio::test]
1469    async fn shell_spawn_failure_when_workdir_missing() {
1470        // A workdir that doesn't exist on disk makes Command::output() fail
1471        // before the shell ever runs (current_dir() can't chdir into it).
1472        // canonicalize() fails for a nonexistent path, so ToolContext::new()
1473        // falls back to keeping the raw (nonexistent) path as-is.
1474        let tools = make_tools(std::path::Path::new(
1475            "/definitely/does/not/exist/leviath-test",
1476        ));
1477        let result = tools.execute("shell", json!({"command": "echo hi"})).await;
1478        assert!(result.contains("[error]"));
1479        assert!(result.contains("Failed to spawn shell"));
1480    }
1481
1482    // ── ToolContext ────────────────────────────────────────────────────────
1483
1484    #[test]
1485    fn tool_context_new_canonicalizes() {
1486        let dir = std::env::temp_dir();
1487        let ctx = ToolContext::new(dir.clone());
1488        // Canonicalized path should be absolute
1489        assert!(ctx.workdir.is_absolute());
1490    }
1491
1492    #[test]
1493    fn tool_context_new_with_nonexistent_dir() {
1494        let ctx = ToolContext::new(PathBuf::from("/nonexistent/path/unlikely"));
1495        // Falls back to the original path when canonicalization fails
1496        assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1497    }
1498
1499    // ── detect_shell ──────────────────────────────────────────────────────
1500
1501    /// The Windows answer, asserted from every platform now that the OS is a
1502    /// parameter rather than a `#[cfg]`. `$SHELL` is ignored there even when it
1503    /// is set (Git for Windows sets it to an MSYS path `CreateProcess` cannot
1504    /// run), which is what the second call pins.
1505    #[test]
1506    fn detect_shell_returns_cmd_exe_on_windows() {
1507        let (shell, flag) = BuiltinTools::detect_shell_for("windows", None, &|_| true);
1508        assert_eq!(shell, "cmd.exe");
1509        assert_eq!(flag, "/C");
1510
1511        let (shell, _) =
1512            BuiltinTools::detect_shell_for("windows", Some("/usr/bin/bash".to_string()), &|_| true);
1513        assert_eq!(shell, "cmd.exe", "$SHELL is not consulted on Windows");
1514    }
1515
1516    #[test]
1517    fn detect_shell_returns_valid_shell() {
1518        // Pure reader: `detect_shell()` always returns a non-empty shell (and the
1519        // "-c" flag on non-Windows) regardless of $SHELL, so it is robust to a
1520        // concurrent temp-env writer and needs no serialization of its own.
1521        let (shell, flag) = BuiltinTools::detect_shell();
1522        assert!(!shell.is_empty());
1523        assert!(!flag.is_empty());
1524        #[cfg(not(windows))]
1525        assert_eq!(flag, "-c");
1526    }
1527
1528    /// Drives the real filesystem probe (`shell_path_exists`) through the seam,
1529    /// with an unrecognized `$SHELL` so the candidate loop is reached. Passing
1530    /// `"linux"` rather than the host OS is what lets this run on the Windows
1531    /// leg too - production's probe would otherwise be a function no Windows
1532    /// test ever calls.
1533    ///
1534    /// The result is host-dependent: a Unix host finds one of the candidates,
1535    /// a Windows host finds none and falls to the last resort. Both are correct,
1536    /// so only the shape is asserted.
1537    #[test]
1538    fn detect_shell_queries_the_real_filesystem_for_an_unrecognized_shell() {
1539        let (shell, flag) = BuiltinTools::detect_shell_for(
1540            "linux",
1541            Some("/opt/not-a-recognized-shell".to_string()),
1542            &BuiltinTools::shell_path_exists,
1543        );
1544        assert_eq!(flag, "-c");
1545        assert!(
1546            [
1547                "/bin/bash",
1548                "/usr/bin/bash",
1549                "/bin/zsh",
1550                "/usr/bin/zsh",
1551                "/bin/sh",
1552                "sh",
1553            ]
1554            .contains(&shell),
1555            "unexpected shell: {shell}"
1556        );
1557    }
1558
1559    // ── detect_shell_for() - inject OS, env and filesystem for full branch coverage ──
1560
1561    #[test]
1562    fn detect_shell_for_returns_zsh_from_env() {
1563        // `$SHELL` is trusted only when it exists on disk.
1564        let (shell, flag) =
1565            BuiltinTools::detect_shell_for("linux", Some("/usr/local/bin/zsh".to_string()), &|s| {
1566                s == "/usr/local/bin/zsh"
1567            });
1568        assert_eq!(shell, "/usr/local/bin/zsh");
1569        assert_eq!(flag, "-c");
1570    }
1571
1572    #[test]
1573    fn detect_shell_for_returns_bash_from_env() {
1574        let (shell, flag) = BuiltinTools::detect_shell_for(
1575            "macos",
1576            Some("/usr/local/bin/bash".to_string()),
1577            &|s| s == "/usr/local/bin/bash",
1578        );
1579        assert_eq!(shell, "/usr/local/bin/bash");
1580        assert_eq!(flag, "-c");
1581    }
1582
1583    #[test]
1584    fn detect_shell_for_returns_sh_from_env() {
1585        // An OS nobody special-cases still gets the POSIX treatment rather than
1586        // falling into the Windows arm.
1587        let (shell, flag) =
1588            BuiltinTools::detect_shell_for("freebsd", Some("/usr/bin/sh".to_string()), &|s| {
1589                s == "/usr/bin/sh"
1590            });
1591        assert_eq!(shell, "/usr/bin/sh");
1592        assert_eq!(flag, "-c");
1593    }
1594
1595    #[test]
1596    fn detect_shell_for_falls_back_when_env_shell_is_missing() {
1597        // Regression for #79: `$SHELL` is a recognized shell name but does not
1598        // exist on disk (a stale or sandbox-missing `/bin/zsh`). It must NOT be
1599        // returned - fall through to an available fallback instead of failing
1600        // every shell call with "No such file or directory".
1601        let (shell, flag) =
1602            BuiltinTools::detect_shell_for("linux", Some("/bin/zsh".to_string()), &|s| {
1603                s == "/bin/sh"
1604            });
1605        assert_eq!(shell, "/bin/sh");
1606        assert_eq!(flag, "-c");
1607    }
1608
1609    #[test]
1610    fn detect_shell_for_falls_through_when_env_unrecognized() {
1611        // /opt/fish doesn't end with /zsh, /bash, or /sh → falls to candidate loop
1612        let (shell, flag) =
1613            BuiltinTools::detect_shell_for("linux", Some("/opt/fish".to_string()), &|s| {
1614                s == "/bin/bash"
1615            });
1616        assert_eq!(shell, "/bin/bash");
1617        assert_eq!(flag, "-c");
1618    }
1619
1620    #[test]
1621    fn detect_shell_for_skips_missing_candidates_and_finds_zsh() {
1622        // bash paths return false; /bin/zsh exists - covers shell_exists false branch
1623        let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|s| s == "/bin/zsh");
1624        assert_eq!(shell, "/bin/zsh");
1625        assert_eq!(flag, "-c");
1626    }
1627
1628    #[test]
1629    fn detect_shell_for_returns_last_resort_when_nothing_exists() {
1630        let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|_| false);
1631        assert_eq!(shell, "sh");
1632        assert_eq!(flag, "-c");
1633    }
1634
1635    #[tokio::test]
1636    async fn concurrent_edits_same_file_serialize_no_lost_update() {
1637        // Two workers edit different unique strings in the SAME file at once.
1638        // The per-path lock serializes the read-modify-write, so both edits
1639        // land; without it, the second write would clobber the first.
1640        let dir = tempfile::tempdir().unwrap();
1641        std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1642        let tools = std::sync::Arc::new(make_tools(dir.path()));
1643
1644        let t1 = {
1645            let t = tools.clone();
1646            tokio::spawn(async move {
1647                t.execute(
1648                    "edit_file",
1649                    json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1650                )
1651                .await
1652            })
1653        };
1654        let t2 = {
1655            let t = tools.clone();
1656            tokio::spawn(async move {
1657                t.execute(
1658                    "edit_file",
1659                    json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1660                )
1661                .await
1662            })
1663        };
1664        let (r1, r2) = tokio::join!(t1, t2);
1665        assert!(!r1.unwrap().starts_with("[error]"));
1666        assert!(!r2.unwrap().starts_with("[error]"));
1667
1668        let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1669        assert_eq!(
1670            final_content, "A1\nB2\n",
1671            "both concurrent edits must apply (no lost update)"
1672        );
1673    }
1674
1675    #[tokio::test]
1676    async fn concurrent_writes_different_files_both_succeed() {
1677        // Different files never contend on the per-path lock.
1678        let dir = tempfile::tempdir().unwrap();
1679        let tools = std::sync::Arc::new(make_tools(dir.path()));
1680
1681        let a = {
1682            let t = tools.clone();
1683            tokio::spawn(async move {
1684                t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
1685                    .await
1686            })
1687        };
1688        let b = {
1689            let t = tools.clone();
1690            tokio::spawn(async move {
1691                t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
1692                    .await
1693            })
1694        };
1695        let (ra, rb) = tokio::join!(a, b);
1696        assert!(!ra.unwrap().starts_with("[error]"));
1697        assert!(!rb.unwrap().starts_with("[error]"));
1698        assert_eq!(
1699            std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
1700            "AAA"
1701        );
1702        assert_eq!(
1703            std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
1704            "BBB"
1705        );
1706    }
1707
1708    // ── Platform capabilities ─────────────────────────────────────────────
1709
1710    #[test]
1711    fn desktop_supports_all_capabilities() {
1712        let caps = PlatformCapabilities::desktop();
1713        assert!(caps.supports(ToolCapability::ProcessSpawn));
1714        assert!(caps.supports(ToolCapability::FileSystem));
1715        assert!(caps.supports(ToolCapability::Network));
1716    }
1717
1718    #[test]
1719    fn mobile_lacks_process_spawn() {
1720        let caps = PlatformCapabilities::mobile();
1721        assert!(!caps.supports(ToolCapability::ProcessSpawn));
1722        assert!(caps.supports(ToolCapability::FileSystem));
1723        assert!(caps.supports(ToolCapability::Network));
1724    }
1725
1726    #[test]
1727    fn current_matches_desktop_and_is_the_default() {
1728        // Only desktop targets are built today.
1729        assert_eq!(
1730            PlatformCapabilities::current(),
1731            PlatformCapabilities::desktop()
1732        );
1733        assert_eq!(
1734            PlatformCapabilities::default(),
1735            PlatformCapabilities::desktop()
1736        );
1737    }
1738
1739    #[test]
1740    fn satisfies_requires_all_and_empty_is_always_met() {
1741        let caps = PlatformCapabilities::mobile();
1742        assert!(caps.satisfies(&[]));
1743        assert!(caps.satisfies(&[ToolCapability::FileSystem]));
1744        assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
1745        // All-or-nothing: one unmet requirement fails the whole set.
1746        assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
1747    }
1748
1749    #[test]
1750    fn from_capabilities_builds_explicit_set() {
1751        let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
1752        assert!(caps.supports(ToolCapability::Network));
1753        assert!(!caps.supports(ToolCapability::FileSystem));
1754    }
1755
1756    #[test]
1757    fn tool_required_capabilities_by_name() {
1758        assert_eq!(
1759            tool_required_capabilities("shell"),
1760            &[ToolCapability::ProcessSpawn]
1761        );
1762        assert_eq!(
1763            tool_required_capabilities("read_file"),
1764            &[ToolCapability::FileSystem]
1765        );
1766        // Runtime-handled / platform-agnostic tools require nothing.
1767        assert!(tool_required_capabilities("context_write").is_empty());
1768        assert!(tool_required_capabilities("present_for_review").is_empty());
1769        assert!(tool_required_capabilities("unknown_tool").is_empty());
1770    }
1771
1772    #[test]
1773    fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
1774        let dir = std::env::temp_dir();
1775        let tools = make_mobile_tools(&dir);
1776        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1777        assert!(!names.contains(&"shell".to_string()));
1778        // The other 15 built-ins remain.
1779        assert_eq!(tools.tool_defs().len(), 15);
1780        assert!(names.contains(&"read_file".to_string()));
1781        assert!(names.contains(&"context_write".to_string()));
1782        assert!(names.contains(&"present_for_review".to_string()));
1783    }
1784
1785    #[test]
1786    fn desktop_tool_defs_include_shell() {
1787        let dir = std::env::temp_dir();
1788        let tools = make_tools(&dir);
1789        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1790        assert!(names.contains(&"shell".to_string()));
1791    }
1792
1793    #[test]
1794    fn mobile_names_omit_shell_and_bash_alias() {
1795        let dir = std::env::temp_dir();
1796        let tools = make_mobile_tools(&dir);
1797        let names = tools.names();
1798        assert!(!names.contains(&"shell".to_string()));
1799        assert!(!names.contains(&"bash".to_string()));
1800        // File + context tools still recognized.
1801        assert!(names.contains(&"read_file".to_string()));
1802        assert!(names.contains(&"context_write".to_string()));
1803    }
1804
1805    #[test]
1806    fn desktop_names_include_shell_and_bash_alias() {
1807        let dir = std::env::temp_dir();
1808        let tools = make_tools(&dir);
1809        let names = tools.names();
1810        assert!(names.contains(&"shell".to_string()));
1811        assert!(names.contains(&"bash".to_string()));
1812    }
1813
1814    #[tokio::test]
1815    async fn mobile_execute_shell_is_rejected() {
1816        let dir = tempfile::tempdir().unwrap();
1817        let tools = make_mobile_tools(dir.path());
1818        let out = tools.execute("shell", json!({"command": "echo hi"})).await;
1819        assert!(out.contains("not available on this platform"), "got: {out}");
1820        // The `bash` alias resolves to `shell` and is rejected the same way.
1821        let out = tools.execute("bash", json!({"command": "echo hi"})).await;
1822        assert!(out.contains("not available on this platform"), "got: {out}");
1823    }
1824
1825    #[tokio::test]
1826    async fn mobile_execute_file_tool_still_works() {
1827        let dir = tempfile::tempdir().unwrap();
1828        let tools = make_mobile_tools(dir.path());
1829        let out = tools
1830            .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
1831            .await;
1832        assert!(!out.starts_with("[error]"), "got: {out}");
1833        assert_eq!(
1834            std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
1835            "hi"
1836        );
1837    }
1838}