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    /// Windows' `detect_shell()` branch is a plain, unconditional constant
1502    /// return (no env/filesystem dependence to inject) - the
1503    /// platform-agnostic `detect_shell_returns_valid_shell` test below
1504    /// already exercises it on Windows CI, but this asserts the exact
1505    /// documented return value directly.
1506    #[cfg(windows)]
1507    #[test]
1508    fn detect_shell_returns_cmd_exe() {
1509        let (shell, flag) = BuiltinTools::detect_shell();
1510        assert_eq!(shell, "cmd.exe");
1511        assert_eq!(flag, "/C");
1512    }
1513
1514    #[test]
1515    fn detect_shell_returns_valid_shell() {
1516        // Pure reader: `detect_shell()` always returns a non-empty shell (and the
1517        // "-c" flag on non-Windows) regardless of $SHELL, so it is robust to a
1518        // concurrent temp-env writer and needs no serialization of its own.
1519        let (shell, flag) = BuiltinTools::detect_shell();
1520        assert!(!shell.is_empty());
1521        assert!(!flag.is_empty());
1522        #[cfg(not(windows))]
1523        assert_eq!(flag, "-c");
1524    }
1525
1526    /// Forces `detect_shell()` to exercise the real `shell_exists` closure by
1527    /// temporarily setting $SHELL to an unrecognized path, causing the candidate
1528    /// loop (and the closure) to be reached. `temp_env::with_var` sets the var,
1529    /// runs the closure, and restores it - serialized against every other
1530    /// temp-env test process-wide, so no hand-rolled lock is needed.
1531    #[cfg(not(windows))]
1532    #[test]
1533    fn detect_shell_queries_real_filesystem_for_unrecognized_shell() {
1534        let (shell, flag) =
1535            temp_env::with_var("SHELL", Some("/opt/not-a-recognized-shell"), || {
1536                BuiltinTools::detect_shell()
1537            });
1538        assert_eq!(flag, "-c");
1539        assert!(
1540            [
1541                "/bin/bash",
1542                "/usr/bin/bash",
1543                "/bin/zsh",
1544                "/usr/bin/zsh",
1545                "/bin/sh"
1546            ]
1547            .contains(&shell)
1548        );
1549    }
1550
1551    // ── detect_shell_impl() - inject env and filesystem for full branch coverage ──
1552
1553    #[cfg(not(windows))]
1554    #[test]
1555    fn detect_shell_impl_returns_zsh_from_env() {
1556        // `$SHELL` is trusted only when it exists on disk.
1557        let (shell, flag) =
1558            BuiltinTools::detect_shell_impl(Some("/usr/local/bin/zsh".to_string()), &|s| {
1559                s == "/usr/local/bin/zsh"
1560            });
1561        assert_eq!(shell, "/usr/local/bin/zsh");
1562        assert_eq!(flag, "-c");
1563    }
1564
1565    #[cfg(not(windows))]
1566    #[test]
1567    fn detect_shell_impl_returns_bash_from_env() {
1568        let (shell, flag) =
1569            BuiltinTools::detect_shell_impl(Some("/usr/local/bin/bash".to_string()), &|s| {
1570                s == "/usr/local/bin/bash"
1571            });
1572        assert_eq!(shell, "/usr/local/bin/bash");
1573        assert_eq!(flag, "-c");
1574    }
1575
1576    #[cfg(not(windows))]
1577    #[test]
1578    fn detect_shell_impl_returns_sh_from_env() {
1579        let (shell, flag) =
1580            BuiltinTools::detect_shell_impl(Some("/usr/bin/sh".to_string()), &|s| {
1581                s == "/usr/bin/sh"
1582            });
1583        assert_eq!(shell, "/usr/bin/sh");
1584        assert_eq!(flag, "-c");
1585    }
1586
1587    #[cfg(not(windows))]
1588    #[test]
1589    fn detect_shell_impl_falls_back_when_env_shell_is_missing() {
1590        // Regression for #79: `$SHELL` is a recognized shell name but does not
1591        // exist on disk (a stale or sandbox-missing `/bin/zsh`). It must NOT be
1592        // returned - fall through to an available fallback instead of failing
1593        // every shell call with "No such file or directory".
1594        let (shell, flag) =
1595            BuiltinTools::detect_shell_impl(Some("/bin/zsh".to_string()), &|s| s == "/bin/sh");
1596        assert_eq!(shell, "/bin/sh");
1597        assert_eq!(flag, "-c");
1598    }
1599
1600    #[cfg(not(windows))]
1601    #[test]
1602    fn detect_shell_impl_falls_through_when_env_unrecognized() {
1603        // /opt/fish doesn't end with /zsh, /bash, or /sh → falls to candidate loop
1604        let (shell, flag) =
1605            BuiltinTools::detect_shell_impl(Some("/opt/fish".to_string()), &|s| s == "/bin/bash");
1606        assert_eq!(shell, "/bin/bash");
1607        assert_eq!(flag, "-c");
1608    }
1609
1610    #[cfg(not(windows))]
1611    #[test]
1612    fn detect_shell_impl_skips_missing_candidates_and_finds_zsh() {
1613        // bash paths return false; /bin/zsh exists - covers shell_exists false branch
1614        let (shell, flag) = BuiltinTools::detect_shell_impl(None, &|s| s == "/bin/zsh");
1615        assert_eq!(shell, "/bin/zsh");
1616        assert_eq!(flag, "-c");
1617    }
1618
1619    #[cfg(not(windows))]
1620    #[test]
1621    fn detect_shell_impl_returns_last_resort_when_nothing_exists() {
1622        let (shell, flag) = BuiltinTools::detect_shell_impl(None, &|_| false);
1623        assert_eq!(shell, "sh");
1624        assert_eq!(flag, "-c");
1625    }
1626
1627    #[tokio::test]
1628    async fn concurrent_edits_same_file_serialize_no_lost_update() {
1629        // Two workers edit different unique strings in the SAME file at once.
1630        // The per-path lock serializes the read-modify-write, so both edits
1631        // land; without it, the second write would clobber the first.
1632        let dir = tempfile::tempdir().unwrap();
1633        std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1634        let tools = std::sync::Arc::new(make_tools(dir.path()));
1635
1636        let t1 = {
1637            let t = tools.clone();
1638            tokio::spawn(async move {
1639                t.execute(
1640                    "edit_file",
1641                    json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1642                )
1643                .await
1644            })
1645        };
1646        let t2 = {
1647            let t = tools.clone();
1648            tokio::spawn(async move {
1649                t.execute(
1650                    "edit_file",
1651                    json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1652                )
1653                .await
1654            })
1655        };
1656        let (r1, r2) = tokio::join!(t1, t2);
1657        assert!(!r1.unwrap().starts_with("[error]"));
1658        assert!(!r2.unwrap().starts_with("[error]"));
1659
1660        let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1661        assert_eq!(
1662            final_content, "A1\nB2\n",
1663            "both concurrent edits must apply (no lost update)"
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn concurrent_writes_different_files_both_succeed() {
1669        // Different files never contend on the per-path lock.
1670        let dir = tempfile::tempdir().unwrap();
1671        let tools = std::sync::Arc::new(make_tools(dir.path()));
1672
1673        let a = {
1674            let t = tools.clone();
1675            tokio::spawn(async move {
1676                t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
1677                    .await
1678            })
1679        };
1680        let b = {
1681            let t = tools.clone();
1682            tokio::spawn(async move {
1683                t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
1684                    .await
1685            })
1686        };
1687        let (ra, rb) = tokio::join!(a, b);
1688        assert!(!ra.unwrap().starts_with("[error]"));
1689        assert!(!rb.unwrap().starts_with("[error]"));
1690        assert_eq!(
1691            std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
1692            "AAA"
1693        );
1694        assert_eq!(
1695            std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
1696            "BBB"
1697        );
1698    }
1699
1700    // ── Platform capabilities ─────────────────────────────────────────────
1701
1702    #[test]
1703    fn desktop_supports_all_capabilities() {
1704        let caps = PlatformCapabilities::desktop();
1705        assert!(caps.supports(ToolCapability::ProcessSpawn));
1706        assert!(caps.supports(ToolCapability::FileSystem));
1707        assert!(caps.supports(ToolCapability::Network));
1708    }
1709
1710    #[test]
1711    fn mobile_lacks_process_spawn() {
1712        let caps = PlatformCapabilities::mobile();
1713        assert!(!caps.supports(ToolCapability::ProcessSpawn));
1714        assert!(caps.supports(ToolCapability::FileSystem));
1715        assert!(caps.supports(ToolCapability::Network));
1716    }
1717
1718    #[test]
1719    fn current_matches_desktop_and_is_the_default() {
1720        // Only desktop targets are built today.
1721        assert_eq!(
1722            PlatformCapabilities::current(),
1723            PlatformCapabilities::desktop()
1724        );
1725        assert_eq!(
1726            PlatformCapabilities::default(),
1727            PlatformCapabilities::desktop()
1728        );
1729    }
1730
1731    #[test]
1732    fn satisfies_requires_all_and_empty_is_always_met() {
1733        let caps = PlatformCapabilities::mobile();
1734        assert!(caps.satisfies(&[]));
1735        assert!(caps.satisfies(&[ToolCapability::FileSystem]));
1736        assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
1737        // All-or-nothing: one unmet requirement fails the whole set.
1738        assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
1739    }
1740
1741    #[test]
1742    fn from_capabilities_builds_explicit_set() {
1743        let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
1744        assert!(caps.supports(ToolCapability::Network));
1745        assert!(!caps.supports(ToolCapability::FileSystem));
1746    }
1747
1748    #[test]
1749    fn tool_required_capabilities_by_name() {
1750        assert_eq!(
1751            tool_required_capabilities("shell"),
1752            &[ToolCapability::ProcessSpawn]
1753        );
1754        assert_eq!(
1755            tool_required_capabilities("read_file"),
1756            &[ToolCapability::FileSystem]
1757        );
1758        // Runtime-handled / platform-agnostic tools require nothing.
1759        assert!(tool_required_capabilities("context_write").is_empty());
1760        assert!(tool_required_capabilities("present_for_review").is_empty());
1761        assert!(tool_required_capabilities("unknown_tool").is_empty());
1762    }
1763
1764    #[test]
1765    fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
1766        let dir = std::env::temp_dir();
1767        let tools = make_mobile_tools(&dir);
1768        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1769        assert!(!names.contains(&"shell".to_string()));
1770        // The other 15 built-ins remain.
1771        assert_eq!(tools.tool_defs().len(), 15);
1772        assert!(names.contains(&"read_file".to_string()));
1773        assert!(names.contains(&"context_write".to_string()));
1774        assert!(names.contains(&"present_for_review".to_string()));
1775    }
1776
1777    #[test]
1778    fn desktop_tool_defs_include_shell() {
1779        let dir = std::env::temp_dir();
1780        let tools = make_tools(&dir);
1781        let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
1782        assert!(names.contains(&"shell".to_string()));
1783    }
1784
1785    #[test]
1786    fn mobile_names_omit_shell_and_bash_alias() {
1787        let dir = std::env::temp_dir();
1788        let tools = make_mobile_tools(&dir);
1789        let names = tools.names();
1790        assert!(!names.contains(&"shell".to_string()));
1791        assert!(!names.contains(&"bash".to_string()));
1792        // File + context tools still recognized.
1793        assert!(names.contains(&"read_file".to_string()));
1794        assert!(names.contains(&"context_write".to_string()));
1795    }
1796
1797    #[test]
1798    fn desktop_names_include_shell_and_bash_alias() {
1799        let dir = std::env::temp_dir();
1800        let tools = make_tools(&dir);
1801        let names = tools.names();
1802        assert!(names.contains(&"shell".to_string()));
1803        assert!(names.contains(&"bash".to_string()));
1804    }
1805
1806    #[tokio::test]
1807    async fn mobile_execute_shell_is_rejected() {
1808        let dir = tempfile::tempdir().unwrap();
1809        let tools = make_mobile_tools(dir.path());
1810        let out = tools.execute("shell", json!({"command": "echo hi"})).await;
1811        assert!(out.contains("not available on this platform"), "got: {out}");
1812        // The `bash` alias resolves to `shell` and is rejected the same way.
1813        let out = tools.execute("bash", json!({"command": "echo hi"})).await;
1814        assert!(out.contains("not available on this platform"), "got: {out}");
1815    }
1816
1817    #[tokio::test]
1818    async fn mobile_execute_file_tool_still_works() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let tools = make_mobile_tools(dir.path());
1821        let out = tools
1822            .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
1823            .await;
1824        assert!(!out.starts_with("[error]"), "got: {out}");
1825        assert_eq!(
1826            std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
1827            "hi"
1828        );
1829    }
1830}