1use 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
14mod context;
17mod defs;
18mod exec;
19pub use exec::is_null_device;
20mod platform;
21pub mod validate;
22pub use context::*;
23pub use defs::{SUBAGENT_TOOLS, is_subagent_tool, submit_output_description};
24pub use platform::*;
25pub use validate::*;
26
27pub use leviath_core::blueprint::SUBMIT_OUTPUT_TOOL;
33
34pub struct BuiltinTools {
41 ctx: ToolContext,
42 platform: PlatformCapabilities,
43 shell_executor: Option<Arc<dyn ShellExecutor>>,
45}
46
47impl BuiltinTools {
48 pub fn new(ctx: ToolContext) -> Self {
51 Self {
52 ctx,
53 platform: PlatformCapabilities::current(),
54 shell_executor: None,
55 }
56 }
57
58 pub fn workdir(&self) -> &Path {
70 &self.ctx.workdir
71 }
72
73 pub fn with_shell_executor(mut self, executor: Arc<dyn ShellExecutor>) -> Self {
76 self.shell_executor = Some(executor);
77 self
78 }
79
80 pub fn with_capabilities(ctx: ToolContext, platform: PlatformCapabilities) -> Self {
83 Self {
84 ctx,
85 platform,
86 shell_executor: None,
87 }
88 }
89
90 fn available(&self, canonical_name: &str) -> bool {
92 self.platform
93 .satisfies(tool_required_capabilities(canonical_name))
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use std::fs;
101
102 fn make_tools(dir: &std::path::Path) -> BuiltinTools {
103 BuiltinTools::new(ToolContext::new(dir.to_path_buf()))
104 }
105
106 #[test]
114 fn workdir_reports_the_canonical_directory_the_tools_were_built_over() {
115 let dir = tempfile::tempdir().unwrap();
116 let tools = make_tools(dir.path());
117 let canonical = std::fs::canonicalize(dir.path()).unwrap();
118 assert_eq!(tools.workdir(), canonical);
119 assert!(leviath_core::resolves_within(
122 &tools.workdir().join("out.txt"),
123 tools.workdir()
124 ));
125 }
126
127 fn make_mobile_tools(dir: &std::path::Path) -> BuiltinTools {
130 BuiltinTools::with_capabilities(
131 ToolContext::new(dir.to_path_buf()),
132 PlatformCapabilities::mobile(),
133 )
134 }
135
136 #[test]
137 fn the_shell_tool_advertises_the_shell_this_host_resolved() {
138 let dir = tempfile::tempdir().unwrap();
142 let defs = make_tools(dir.path()).tool_defs();
143 let shell = defs
144 .iter()
145 .find(|t| t.name == "shell")
146 .expect("shell is advertised on a desktop capability set");
147 let (resolved, _) = BuiltinTools::detect_shell();
148 assert!(
149 shell.description.contains(resolved),
150 "description {:?} does not name the resolved shell {resolved:?}",
151 shell.description
152 );
153
154 assert!(crate::defs::shell_tool_description("cmd.exe").contains("`cmd.exe`"));
156 assert!(crate::defs::shell_tool_description("/bin/zsh").contains("`/bin/zsh`"));
157 }
158
159 #[test]
160 fn subagent_predicate_covers_the_five_names_and_nothing_else() {
161 for name in SUBAGENT_TOOLS {
162 assert!(is_subagent_tool(name));
163 }
164 assert!(!is_subagent_tool("read_file"));
165 }
166
167 #[test]
170 fn tool_defs_returns_twenty_tools() {
171 let dir = std::env::temp_dir();
172 let tools = make_tools(&dir);
173 let defs = tools.tool_defs();
174 assert_eq!(defs.len(), 20);
175 }
176
177 #[test]
178 fn tool_defs_names_are_correct() {
179 let dir = std::env::temp_dir();
180 let tools = make_tools(&dir);
181 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
182 assert!(names.contains(&"read_file".to_string()));
183 assert!(names.contains(&"read_files".to_string()));
184 assert!(names.contains(&"write_file".to_string()));
185 assert!(names.contains(&"edit_file".to_string()));
186 assert!(names.contains(&"list_dir".to_string()));
187 assert!(names.contains(&"shell".to_string()));
188 assert!(names.contains(&"present_for_review".to_string()));
189 assert!(names.contains(&"ask_user_text".to_string()));
190 assert!(names.contains(&"ask_user_choice".to_string()));
191 assert!(names.contains(&"ask_user_confirm".to_string()));
192 assert!(names.contains(&"edit_document".to_string()));
193 assert!(names.contains(&"context_write".to_string()));
194 assert!(names.contains(&"context_append".to_string()));
195 assert!(names.contains(&"context_read".to_string()));
196 assert!(names.contains(&"context_delete".to_string()));
197 assert!(names.contains(&"context_list".to_string()));
198 }
199
200 #[test]
201 fn tool_defs_edit_document_requires_content() {
202 let dir = std::env::temp_dir();
203 let tools = make_tools(&dir);
204 let def = tools
205 .tool_defs()
206 .into_iter()
207 .find(|t| t.name == "edit_document")
208 .expect("edit_document tool def must exist");
209 let required = def.parameters["required"].as_array().unwrap();
210 assert!(required.iter().any(|v| v == "content"));
211 assert_eq!(def.parameters["properties"]["content"]["type"], "string");
212 assert!(tools.names().contains(&"edit_document".to_string()));
214 }
215
216 #[test]
217 fn tool_defs_ask_user_choice_has_options_array() {
218 let dir = std::env::temp_dir();
219 let tools = make_tools(&dir);
220 let def = tools
221 .tool_defs()
222 .into_iter()
223 .find(|t| t.name == "ask_user_choice")
224 .unwrap();
225 let required = def.parameters["required"].as_array().unwrap();
226 assert!(required.iter().any(|v| v == "prompt"));
227 assert!(required.iter().any(|v| v == "options"));
228 assert_eq!(def.parameters["properties"]["options"]["type"], "array");
229 }
230
231 #[tokio::test]
232 async fn context_tools_return_runtime_error() {
233 let dir = std::env::temp_dir();
234 let tools = make_tools(&dir);
235 for name in [
236 "context_write",
237 "context_append",
238 "context_read",
239 "context_delete",
240 "context_list",
241 ] {
242 let result = tools.execute(name, serde_json::json!({})).await;
243 assert!(result.contains("context tools must be handled by the runtime"));
244 }
245 }
246
247 #[tokio::test]
252 async fn submit_output_is_not_handled_by_builtin_execute() {
253 let dir = std::env::temp_dir();
254 let tools = make_tools(&dir);
255 let result = tools
256 .execute(
257 crate::SUBMIT_OUTPUT_TOOL,
258 serde_json::json!({"content": "the answer"}),
259 )
260 .await;
261 assert!(
262 result.contains("submit_output must be handled by the runtime"),
263 "{result}"
264 );
265 }
266
267 #[test]
271 fn the_submit_description_carries_a_declared_shape_and_nothing_otherwise() {
272 let generic = submit_output_description("");
273 assert!(generic.contains("artifacts"), "{generic}");
274 assert!(!generic.contains("a2ui"));
275
276 let shaped = submit_output_description("Return it in this format: a2ui.");
277 assert!(shaped.starts_with(&generic), "the generic part is kept");
278 assert!(shaped.ends_with("Return it in this format: a2ui."));
279 }
280
281 #[tokio::test]
282 async fn ask_user_tools_not_handled_by_builtin_execute() {
283 let dir = std::env::temp_dir();
286 let tools = make_tools(&dir);
287 for name in [
288 "ask_user_text",
289 "ask_user_choice",
290 "ask_user_confirm",
291 "edit_document",
292 ] {
293 let result = tools.execute(name, serde_json::json!({})).await;
294 assert!(result.contains("Unknown built-in tool"));
295 }
296 }
297
298 #[test]
299 fn context_tool_descriptions_mention_key_concepts() {
300 let dir = std::env::temp_dir();
301 let tools = make_tools(&dir);
302 let defs = tools.tool_defs();
303
304 let write_def = defs.iter().find(|t| t.name == "context_write").unwrap();
305 assert!(
306 write_def.description.contains("system prompt"),
307 "context_write should mention system prompt: {}",
308 write_def.description
309 );
310 assert!(
311 write_def.description.contains("replaced"),
312 "context_write should mention replacement: {}",
313 write_def.description
314 );
315
316 let read_def = defs.iter().find(|t| t.name == "context_read").unwrap();
317 assert!(
318 read_def.description.contains("summary"),
319 "context_read should mention summary: {}",
320 read_def.description
321 );
322
323 let list_def = defs.iter().find(|t| t.name == "context_list").unwrap();
324 assert!(
325 list_def.description.contains("token"),
326 "context_list should mention tokens: {}",
327 list_def.description
328 );
329
330 let append_def = defs.iter().find(|t| t.name == "context_append").unwrap();
331 assert!(
332 append_def.description.contains("without replacing"),
333 "context_append should mention 'without replacing': {}",
334 append_def.description
335 );
336 }
337
338 fn assert_has_description(name: &str, description: &str) {
339 assert!(
340 !description.is_empty(),
341 "tool {} has empty description",
342 name
343 );
344 }
345
346 fn assert_has_object_params(name: &str, params: &serde_json::Value) {
347 assert!(params.is_object(), "tool {} has non-object params", name);
348 }
349
350 #[test]
351 fn tool_defs_have_descriptions() {
352 let dir = std::env::temp_dir();
353 let tools = make_tools(&dir);
354 for def in tools.tool_defs() {
355 assert_has_description(&def.name, &def.description);
356 }
357 }
358
359 #[test]
360 #[should_panic(expected = "tool bogus has empty description")]
361 fn tool_defs_have_descriptions_panics_on_empty_description() {
362 assert_has_description("bogus", "");
363 }
364
365 #[test]
366 fn tool_defs_have_parameters() {
367 let dir = std::env::temp_dir();
368 let tools = make_tools(&dir);
369 for def in tools.tool_defs() {
370 assert_has_object_params(&def.name, &def.parameters);
371 }
372 }
373
374 #[test]
375 #[should_panic(expected = "tool bogus has non-object params")]
376 fn tool_defs_have_parameters_panics_on_non_object_params() {
377 assert_has_object_params("bogus", &serde_json::Value::Null);
378 }
379
380 #[test]
383 fn names_includes_bash_alias() {
384 let dir = std::env::temp_dir();
385 let tools = make_tools(&dir);
386 let names = tools.names();
387 assert!(names.contains(&"bash".to_string()));
388 assert!(names.contains(&"shell".to_string()));
389 }
390
391 #[test]
395 fn tool_name_spellings_covers_both_directions_without_repeating() {
396 fn of(n: &str) -> Vec<&str> {
397 tool_name_spellings(n).collect()
398 }
399 assert_eq!(of("shell"), ["shell", "bash"]);
400 assert_eq!(of("bash"), ["bash", "shell"]);
401 assert_eq!(of("read_file"), ["read_file"]);
403 assert_eq!(of("linear__search"), ["linear__search"]);
404 }
405
406 #[test]
407 fn canonical_tool_name_resolves_aliases_and_passes_others_through() {
408 assert_eq!(canonical_tool_name("bash"), "shell");
410 assert_eq!(canonical_tool_name("shell"), "shell");
412 assert_eq!(canonical_tool_name("read_file"), "read_file");
413 assert_eq!(canonical_tool_name("acme__do_thing"), "acme__do_thing");
416 for (alias, canonical) in TOOL_ALIASES {
418 assert_eq!(canonical_tool_name(alias), *canonical);
419 }
420 }
421
422 #[test]
423 fn names_returns_twenty_one_entries() {
424 let dir = std::env::temp_dir();
425 let tools = make_tools(&dir);
426 assert_eq!(tools.names().len(), 21);
427 }
428
429 #[test]
432 fn subagent_tool_defs_returns_five_tools() {
433 let defs = BuiltinTools::subagent_tool_defs();
434 assert_eq!(defs.len(), 5);
435 }
436
437 #[test]
438 fn subagent_tool_names_returns_five_names() {
439 let names = BuiltinTools::subagent_tool_names();
440 assert_eq!(names.len(), 5);
441 assert!(names.contains(&"spawn_agent".to_string()));
442 assert!(names.contains(&"check_agent".to_string()));
443 assert!(names.contains(&"wait_for_agent".to_string()));
444 assert!(names.contains(&"send_to_agent".to_string()));
445 assert!(names.contains(&"kill_agent".to_string()));
446 }
447
448 #[test]
449 fn subagent_tool_defs_names_match_subagent_tool_names() {
450 let defs = BuiltinTools::subagent_tool_defs();
451 let names = BuiltinTools::subagent_tool_names();
452 let def_names: Vec<String> = defs.iter().map(|d| d.name.clone()).collect();
453 assert_eq!(def_names, names);
454 }
455
456 #[test]
459 fn resolve_relative_path() {
460 let dir = std::env::temp_dir();
461 let tools = make_tools(&dir);
462 let result = tools.resolve("hello.txt").unwrap();
463 assert!(result.starts_with(&tools.ctx.workdir));
464 assert!(result.ends_with("hello.txt"));
465 }
466
467 #[test]
468 fn resolve_rejects_path_escape() {
469 let dir = std::env::temp_dir().join("leviath_test_sandbox");
470 fs::create_dir_all(&dir).ok();
471 let tools = make_tools(&dir);
472 let result = tools.resolve("../../etc/passwd");
473 assert!(result.is_err());
474 }
475
476 #[test]
487 fn resolve_refuses_a_path_that_does_not_resolve_within_the_workdir() {
488 fn escapes(_: &Path, _: &Path) -> bool {
489 false
490 }
491 let dir = tempfile::tempdir().unwrap();
492 let err = BuiltinTools::resolve_within("notes.txt", dir.path(), escapes)
493 .expect_err("a path that resolves outside must be refused");
494 assert!(err.to_string().contains("symlink"), "{err}");
495 }
496
497 #[test]
500 fn resolve_admits_an_ordinary_path_within_the_workdir() {
501 let dir = tempfile::tempdir().unwrap();
502 let resolved =
503 BuiltinTools::resolve_within("notes.txt", dir.path(), leviath_core::resolves_within)
504 .expect("an ordinary path resolves");
505 assert!(resolved.ends_with("notes.txt"));
506 }
507
508 #[cfg(unix)]
513 #[tokio::test]
514 async fn resolve_rejects_symlink_escape() {
515 let dir = tempfile::tempdir().unwrap();
516 let workdir = dir.path().join("workspace");
517 fs::create_dir(&workdir).unwrap();
518 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
519 let tools = make_tools(&workdir);
520
521 let normalized = tools.ctx.workdir.join("link/etc/hosts");
526 assert!(normalized.starts_with(&tools.ctx.workdir));
527
528 let err = tools.resolve("link/etc/hosts").unwrap_err().to_string();
529 assert!(err.contains("symlink"), "got: {err}");
530
531 let out = tools.read_file(&json!({ "path": "link/etc/hosts" })).await;
533 assert!(out.contains("[error]"), "got: {out}");
534 }
535
536 #[cfg(unix)]
539 #[tokio::test]
540 async fn write_file_rejects_symlink_escape() {
541 let dir = tempfile::tempdir().unwrap();
542 let outside = tempfile::tempdir().unwrap();
543 let workdir = dir.path().join("workspace");
544 fs::create_dir(&workdir).unwrap();
545 std::os::unix::fs::symlink(outside.path(), workdir.join("link")).unwrap();
546 let tools = make_tools(&workdir);
547
548 let out = tools
549 .write_file(&json!({ "path": "link/pwned.txt", "content": "x" }))
550 .await;
551 assert!(out.contains("[error]"), "got: {out}");
552 assert!(
553 !outside.path().join("pwned.txt").exists(),
554 "nothing may be written outside the workdir"
555 );
556 }
557
558 fn make_tools_with_read_paths(
564 workdir: &std::path::Path,
565 blueprint: &[&str],
566 grants: &[&str],
567 allow_blueprint: bool,
568 ) -> BuiltinTools {
569 let compile = |entries: &[&str]| {
570 let raw: Vec<String> = entries.iter().map(|s| s.to_string()).collect();
571 leviath_core::ReadPathSet::compile(&raw, workdir, None, false)
572 .expect("test entries compile")
573 };
574 let policy = leviath_core::ReadPathPolicy {
575 agent: "tester".into(),
576 blueprint: compile(blueprint),
577 grants: compile(grants),
578 allow_blueprint,
579 };
580 BuiltinTools::new(ToolContext::new(workdir.to_path_buf()).with_read_paths(policy))
581 }
582
583 #[tokio::test]
586 async fn read_tools_reach_a_declared_and_granted_outside_path() {
587 let dir = tempfile::tempdir().unwrap();
588 let outside = tempfile::tempdir().unwrap();
589 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
590 let entry = outside.path().to_str().unwrap();
591 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
592
593 let target = outside.path().join("doc.md");
594 let target = target.to_str().unwrap();
595 let out = tools.read_file(&json!({ "path": target })).await;
596 assert_eq!(out, "outside contents");
597
598 let listed = tools
599 .list_dir(&json!({ "path": outside.path().to_str().unwrap() }))
600 .await;
601 assert!(listed.contains("doc.md"), "got: {listed}");
602
603 fs::write(dir.path().join("inside.txt"), "inside contents").unwrap();
605 let out = tools
606 .read_files(&json!({ "paths": ["inside.txt", target] }))
607 .await;
608 assert!(out.contains("inside contents"), "got: {out}");
609 assert!(out.contains("outside contents"), "got: {out}");
610 }
611
612 #[tokio::test]
616 async fn write_and_edit_stay_confined_despite_read_grants() {
617 let dir = tempfile::tempdir().unwrap();
618 let outside = tempfile::tempdir().unwrap();
619 fs::write(outside.path().join("doc.md"), "original").unwrap();
620 let entry = outside.path().to_str().unwrap();
621 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
622
623 let target = outside.path().join("doc.md");
624 let target = target.to_str().unwrap();
625 let out = tools
626 .write_file(&json!({ "path": target, "content": "clobbered" }))
627 .await;
628 assert!(out.contains("[error]"), "got: {out}");
629 let out = tools
630 .edit_file(&json!({ "path": target, "old_str": "original", "new_str": "x" }))
631 .await;
632 assert!(out.contains("[error]"), "got: {out}");
633 assert_eq!(
634 fs::read_to_string(outside.path().join("doc.md")).unwrap(),
635 "original",
636 "a read grant must never permit a write"
637 );
638 }
639
640 #[tokio::test]
643 async fn an_ungranted_declaration_is_refused_with_guidance() {
644 let dir = tempfile::tempdir().unwrap();
645 let outside = tempfile::tempdir().unwrap();
646 fs::write(outside.path().join("doc.md"), "secret").unwrap();
647 let entry = outside.path().to_str().unwrap();
648 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], false);
649
650 let target = outside.path().join("doc.md");
651 let out = tools
652 .read_file(&json!({ "path": target.to_str().unwrap() }))
653 .await;
654 assert!(out.contains("[error]"), "got: {out}");
655 assert!(out.contains("does not grant"), "got: {out}");
656 assert!(out.contains("[agent_read_paths.tester]"), "got: {out}");
657 assert!(!out.contains("secret"), "content must not leak");
658 }
659
660 #[tokio::test]
663 async fn the_blanket_override_honors_declarations() {
664 let dir = tempfile::tempdir().unwrap();
665 let outside = tempfile::tempdir().unwrap();
666 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
667 let entry = outside.path().to_str().unwrap();
668 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[], true);
669
670 let target = outside.path().join("doc.md");
671 let out = tools
672 .read_file(&json!({ "path": target.to_str().unwrap() }))
673 .await;
674 assert_eq!(out, "outside contents");
675
676 let undeclared = tempfile::tempdir().unwrap();
678 fs::write(undeclared.path().join("x.txt"), "x").unwrap();
679 let out = tools
680 .read_file(&json!({ "path": undeclared.path().join("x.txt").to_str().unwrap() }))
681 .await;
682 assert!(
683 out.contains("not in this agent's [read_paths]"),
684 "got: {out}"
685 );
686 }
687
688 #[tokio::test]
691 async fn an_inactive_policy_keeps_the_workdir_error() {
692 let dir = tempfile::tempdir().unwrap();
693 let tools = make_tools(dir.path());
694 let out = tools.read_file(&json!({ "path": "/etc/hosts" })).await;
695 assert!(
696 out.contains("would escape the working directory"),
697 "got: {out}"
698 );
699 }
700
701 #[tokio::test]
705 async fn a_relative_request_reaches_a_relative_grant() {
706 let parent = tempfile::tempdir().unwrap();
707 let workdir = parent.path().join("work");
708 let shared = parent.path().join("shared");
709 fs::create_dir_all(&workdir).unwrap();
710 fs::create_dir_all(&shared).unwrap();
711 fs::write(shared.join("doc.md"), "shared contents").unwrap();
712 let tools = make_tools_with_read_paths(&workdir, &["../shared"], &["../shared"], false);
713
714 let out = tools
715 .read_file(&json!({ "path": "../shared/doc.md" }))
716 .await;
717 assert_eq!(out, "shared contents");
718 }
719
720 #[tokio::test]
724 async fn a_dot_component_is_folded_in_the_fallback() {
725 let dir = tempfile::tempdir().unwrap();
726 let outside = tempfile::tempdir().unwrap();
727 fs::write(outside.path().join("doc.md"), "outside contents").unwrap();
728 let entry = outside.path().to_str().unwrap();
729 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
730
731 let target = format!("{}/./doc.md", outside.path().to_str().unwrap());
732 let out = tools.read_file(&json!({ "path": target })).await;
733 assert_eq!(out, "outside contents");
734 }
735
736 #[test]
745 fn folding_past_the_root_is_unresolvable() {
746 let policy = leviath_core::ReadPathPolicy {
747 agent: "tester".into(),
748 allow_blueprint: true,
749 ..Default::default()
750 };
751 let err = BuiltinTools::resolve_outside(
752 "../../x",
753 Path::new("wd"),
754 &policy,
755 leviath_core::canonicalize_for_match,
756 )
757 .expect_err("popping past the top must be refused");
758 assert!(err.to_string().contains("cannot be resolved"), "{err}");
759 }
760
761 #[test]
765 fn an_unverifiable_path_is_refused() {
766 fn unverifiable(_: &Path) -> Option<PathBuf> {
767 None
768 }
769 let policy = leviath_core::ReadPathPolicy {
770 agent: "tester".into(),
771 allow_blueprint: true,
772 ..Default::default()
773 };
774 let err =
775 BuiltinTools::resolve_outside("/outside/x", Path::new("/w"), &policy, unverifiable)
776 .expect_err("an unverifiable path must be refused");
777 assert!(err.to_string().contains("cannot be verified"), "{err}");
778 }
779
780 #[cfg(unix)]
784 #[tokio::test]
785 async fn a_symlink_inside_a_granted_directory_cannot_escape_it() {
786 let dir = tempfile::tempdir().unwrap();
787 let granted = tempfile::tempdir().unwrap();
788 let secret_home = tempfile::tempdir().unwrap();
789 fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
790 std::os::unix::fs::symlink(
791 secret_home.path().join("id_rsa"),
792 granted.path().join("innocent.md"),
793 )
794 .unwrap();
795 let entry = granted.path().to_str().unwrap();
796 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
797
798 let out = tools
799 .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
800 .await;
801 assert!(out.contains("[error]"), "got: {out}");
802 assert!(!out.contains("PRIVATE KEY"), "content must not leak");
803 }
804
805 #[cfg(unix)]
809 #[tokio::test]
810 async fn a_glob_grant_is_symlink_safe() {
811 let dir = tempfile::tempdir().unwrap();
812 let granted = tempfile::tempdir().unwrap();
813 let secret_home = tempfile::tempdir().unwrap();
814 fs::write(secret_home.path().join("id_rsa"), "PRIVATE KEY").unwrap();
815 std::os::unix::fs::symlink(
816 secret_home.path().join("id_rsa"),
817 granted.path().join("innocent.md"),
818 )
819 .unwrap();
820 let canonical = fs::canonicalize(granted.path()).unwrap();
822 let entry = format!("glob:{}/**", canonical.display());
823 let tools = make_tools_with_read_paths(dir.path(), &[&entry], &[&entry], false);
824
825 let out = tools
826 .read_file(&json!({ "path": granted.path().join("innocent.md").to_str().unwrap() }))
827 .await;
828 assert!(out.contains("[error]"), "got: {out}");
829 assert!(!out.contains("PRIVATE KEY"), "content must not leak");
830
831 fs::write(granted.path().join("real.md"), "real contents").unwrap();
834 let out = tools
835 .read_file(&json!({ "path": granted.path().join("real.md").to_str().unwrap() }))
836 .await;
837 assert_eq!(out, "real contents");
838 }
839
840 #[cfg(unix)]
843 #[tokio::test]
844 async fn a_symlink_within_a_granted_directory_is_readable() {
845 let dir = tempfile::tempdir().unwrap();
846 let granted = tempfile::tempdir().unwrap();
847 fs::create_dir(granted.path().join("real")).unwrap();
848 fs::write(granted.path().join("real/doc.md"), "granted contents").unwrap();
849 std::os::unix::fs::symlink(granted.path().join("real"), granted.path().join("link"))
850 .unwrap();
851 let entry = granted.path().to_str().unwrap();
852 let tools = make_tools_with_read_paths(dir.path(), &[entry], &[entry], false);
853
854 let out = tools
855 .read_file(&json!({ "path": granted.path().join("link/doc.md").to_str().unwrap() }))
856 .await;
857 assert_eq!(out, "granted contents");
858 }
859
860 #[cfg(unix)]
864 #[tokio::test]
865 async fn resolve_allows_symlink_within_workdir() {
866 let dir = tempfile::tempdir().unwrap();
867 let workdir = dir.path().join("workspace");
868 fs::create_dir(&workdir).unwrap();
869 fs::create_dir(workdir.join("real")).unwrap();
870 fs::write(workdir.join("real/file.txt"), "contents").unwrap();
871 std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")).unwrap();
872 let tools = make_tools(&workdir);
873
874 assert!(tools.resolve("link/file.txt").is_ok());
875 let out = tools.read_file(&json!({ "path": "link/file.txt" })).await;
876 assert_eq!(out, "contents");
877 }
878
879 #[test]
880 fn resolve_dot_stays_in_workdir() {
881 let dir = std::env::temp_dir();
882 let tools = make_tools(&dir);
883 let result = tools.resolve("./foo/./bar.txt").unwrap();
884 assert!(result.starts_with(&tools.ctx.workdir));
885 assert!(result.ends_with("foo/bar.txt"));
886 }
887
888 #[tokio::test]
891 async fn execute_unknown_tool_returns_error() {
892 let dir = std::env::temp_dir();
893 let tools = make_tools(&dir);
894 let result = tools.execute("nonexistent", json!({})).await;
895 assert!(result.contains("[error]"));
896 assert!(result.contains("Unknown built-in tool"));
897 }
898
899 #[tokio::test]
900 async fn read_file_missing_path_arg() {
901 let dir = std::env::temp_dir();
902 let tools = make_tools(&dir);
903 let result = tools.execute("read_file", json!({})).await;
904 assert!(result.contains("[error]"));
905 assert!(result.contains("missing 'path'"));
906 }
907
908 #[tokio::test]
909 async fn write_and_read_file_roundtrip() {
910 let dir = tempfile::tempdir().unwrap();
911 let tools = make_tools(dir.path());
912
913 let write_result = tools
914 .execute(
915 "write_file",
916 json!({"path": "test.txt", "content": "hello world"}),
917 )
918 .await;
919 assert!(write_result.contains("Successfully wrote"));
920 assert!(write_result.contains("11 bytes"));
921
922 let read_result = tools
923 .execute("read_file", json!({"path": "test.txt"}))
924 .await;
925 assert_eq!(read_result, "hello world");
926 }
927
928 #[tokio::test]
929 async fn write_file_creates_parent_dirs() {
930 let dir = tempfile::tempdir().unwrap();
931 let tools = make_tools(dir.path());
932
933 let result = tools
934 .execute(
935 "write_file",
936 json!({"path": "sub/dir/file.txt", "content": "nested"}),
937 )
938 .await;
939 assert!(result.contains("Successfully wrote"));
940 assert!(dir.path().join("sub/dir/file.txt").exists());
941 }
942
943 #[tokio::test]
944 async fn write_tools_refuse_to_resurrect_a_deleted_workspace() {
945 let dir = tempfile::tempdir().unwrap();
951 let workdir = dir.path().join("workspace");
952 fs::create_dir(&workdir).unwrap();
953 fs::write(workdir.join("a.txt"), "before").unwrap();
954 let tools = make_tools(&workdir);
955 fs::remove_dir_all(&workdir).unwrap();
956
957 for (tool, args) in [
958 ("write_file", json!({"path": "a.txt", "content": "after"})),
959 (
960 "edit_file",
961 json!({"path": "a.txt", "old_str": "before", "new_str": "after"}),
962 ),
963 ] {
964 let result = tools.execute(tool, args).await;
965 assert!(
966 result.contains("workspace") && result.contains("no longer accessible"),
967 "{tool} got: {result}"
968 );
969 }
970 assert!(!workdir.exists(), "the workspace must stay gone");
971 }
972
973 #[tokio::test]
974 async fn write_file_missing_content_arg() {
975 let dir = tempfile::tempdir().unwrap();
976 let tools = make_tools(dir.path());
977 let result = tools.execute("write_file", json!({"path": "f.txt"})).await;
978 assert!(result.contains("missing 'content'"));
979 }
980
981 #[tokio::test]
982 async fn write_file_missing_path_arg() {
983 let dir = tempfile::tempdir().unwrap();
984 let tools = make_tools(dir.path());
985 let result = tools.execute("write_file", json!({"content": "x"})).await;
986 assert!(result.contains("missing 'path'"));
987 }
988
989 #[test]
990 fn resolve_rejects_excessive_parent_dir_traversal() {
991 let tools = BuiltinTools::new(ToolContext::new(PathBuf::from(
1006 "leviath-nonexistent-relative-workdir",
1007 )));
1008 let result = tools.resolve("../../etc/passwd");
1009 assert!(result.is_err());
1010 assert!(
1011 result
1012 .unwrap_err()
1013 .to_string()
1014 .contains("escapes the working directory")
1015 );
1016 }
1017
1018 #[tokio::test]
1019 async fn edit_file_successful_replacement() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let tools = make_tools(dir.path());
1022
1023 tools
1024 .execute(
1025 "write_file",
1026 json!({"path": "e.txt", "content": "foo bar baz"}),
1027 )
1028 .await;
1029
1030 let result = tools
1031 .execute(
1032 "edit_file",
1033 json!({"path": "e.txt", "old_str": "bar", "new_str": "qux"}),
1034 )
1035 .await;
1036 assert!(result.contains("Successfully edited"));
1037
1038 let content = tools.execute("read_file", json!({"path": "e.txt"})).await;
1039 assert_eq!(content, "foo qux baz");
1040 }
1041
1042 #[tokio::test]
1043 async fn edit_file_string_not_found() {
1044 let dir = tempfile::tempdir().unwrap();
1045 let tools = make_tools(dir.path());
1046
1047 tools
1048 .execute("write_file", json!({"path": "e.txt", "content": "abc"}))
1049 .await;
1050
1051 let result = tools
1052 .execute(
1053 "edit_file",
1054 json!({"path": "e.txt", "old_str": "xyz", "new_str": "123"}),
1055 )
1056 .await;
1057 assert!(result.contains("String not found"));
1058 }
1059
1060 #[tokio::test]
1061 async fn edit_file_missing_file_returns_read_error() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let tools = make_tools(dir.path());
1064
1065 let result = tools
1066 .execute(
1067 "edit_file",
1068 json!({"path": "does-not-exist.txt", "old_str": "a", "new_str": "b"}),
1069 )
1070 .await;
1071 assert!(result.contains("[error]"));
1072 assert!(result.contains("Failed to read"));
1073 }
1074
1075 #[tokio::test]
1076 async fn edit_file_multiple_occurrences() {
1077 let dir = tempfile::tempdir().unwrap();
1078 let tools = make_tools(dir.path());
1079
1080 tools
1081 .execute("write_file", json!({"path": "e.txt", "content": "aaa aaa"}))
1082 .await;
1083
1084 let result = tools
1085 .execute(
1086 "edit_file",
1087 json!({"path": "e.txt", "old_str": "aaa", "new_str": "bbb"}),
1088 )
1089 .await;
1090 assert!(result.contains("2 occurrences"));
1091 assert!(result.contains("must be unique"));
1092 }
1093
1094 #[tokio::test]
1095 async fn edit_file_missing_args() {
1096 let dir = tempfile::tempdir().unwrap();
1097 let tools = make_tools(dir.path());
1098
1099 let r1 = tools.execute("edit_file", json!({})).await;
1100 assert!(r1.contains("missing 'path'"));
1101
1102 let r2 = tools.execute("edit_file", json!({"path": "f.txt"})).await;
1103 assert!(r2.contains("missing 'old_str'"));
1104
1105 let r3 = tools
1106 .execute("edit_file", json!({"path": "f.txt", "old_str": "x"}))
1107 .await;
1108 assert!(r3.contains("missing 'new_str'"));
1109 }
1110
1111 #[tokio::test]
1112 async fn list_dir_contents() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let tools = make_tools(dir.path());
1115
1116 fs::write(dir.path().join("a.txt"), "hello").unwrap();
1117 fs::create_dir(dir.path().join("subdir")).unwrap();
1118
1119 let result = tools.execute("list_dir", json!({})).await;
1120 assert!(result.contains("a.txt"));
1121 assert!(result.contains("subdir/"));
1122 }
1123
1124 #[tokio::test]
1125 async fn list_dir_empty() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let tools = make_tools(dir.path());
1128 let result = tools.execute("list_dir", json!({})).await;
1129 assert!(result.contains("empty directory"));
1130 }
1131
1132 #[tokio::test]
1133 async fn list_dir_with_path() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let tools = make_tools(dir.path());
1136
1137 fs::create_dir(dir.path().join("sub")).unwrap();
1138 fs::write(dir.path().join("sub/inner.txt"), "data").unwrap();
1139
1140 let result = tools.execute("list_dir", json!({"path": "sub"})).await;
1141 assert!(result.contains("inner.txt"));
1142 }
1143
1144 #[tokio::test]
1145 async fn read_file_nonexistent() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let tools = make_tools(dir.path());
1148 let result = tools
1149 .execute("read_file", json!({"path": "nope.txt"}))
1150 .await;
1151 assert!(result.contains("[error]"));
1152 assert!(result.contains("Failed to read"));
1153 }
1154
1155 #[tokio::test]
1158 async fn read_files_multiple_valid_files() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let tools = make_tools(dir.path());
1161 fs::write(dir.path().join("a.txt"), "alpha").unwrap();
1162 fs::write(dir.path().join("b.txt"), "beta").unwrap();
1163
1164 let result = tools
1165 .execute("read_files", json!({"paths": ["a.txt", "b.txt"]}))
1166 .await;
1167 assert!(result.contains("### [a.txt]"));
1168 assert!(result.contains("alpha"));
1169 assert!(result.contains("### [b.txt]"));
1170 assert!(result.contains("beta"));
1171 assert!(result.contains("\n\n"));
1173 }
1174
1175 #[tokio::test]
1176 async fn read_files_missing_paths_arg() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let tools = make_tools(dir.path());
1179 let result = tools.execute("read_files", json!({})).await;
1180 assert!(result.contains("[error]"));
1181 assert!(result.contains("missing 'paths'"));
1182 }
1183
1184 #[tokio::test]
1185 async fn read_files_non_array_paths_arg() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let tools = make_tools(dir.path());
1188 let result = tools.execute("read_files", json!({"paths": "a.txt"})).await;
1190 assert!(result.contains("[error]"));
1191 assert!(result.contains("missing 'paths'"));
1192 }
1193
1194 #[tokio::test]
1195 async fn read_files_empty_paths_array() {
1196 let dir = tempfile::tempdir().unwrap();
1197 let tools = make_tools(dir.path());
1198 let result = tools.execute("read_files", json!({"paths": []})).await;
1199 assert!(result.contains("[error]"));
1200 assert!(result.contains("empty"));
1201 }
1202
1203 #[tokio::test]
1204 async fn read_files_missing_file_reports_per_file_error() {
1205 let dir = tempfile::tempdir().unwrap();
1206 let tools = make_tools(dir.path());
1207 fs::write(dir.path().join("present.txt"), "here").unwrap();
1208
1209 let result = tools
1210 .execute(
1211 "read_files",
1212 json!({"paths": ["present.txt", "absent.txt"]}),
1213 )
1214 .await;
1215 assert!(result.contains("### [present.txt]"));
1217 assert!(result.contains("here"));
1218 assert!(result.contains("### [absent.txt]"));
1220 assert!(result.contains("Failed to read"));
1221 }
1222
1223 #[tokio::test]
1224 async fn read_files_non_string_element_reports_error() {
1225 let dir = tempfile::tempdir().unwrap();
1226 let tools = make_tools(dir.path());
1227 fs::write(dir.path().join("ok.txt"), "content").unwrap();
1228
1229 let result = tools
1230 .execute("read_files", json!({"paths": ["ok.txt", 42]}))
1231 .await;
1232 assert!(result.contains("content"));
1233 assert!(result.contains("non-string path in array"));
1234 }
1235
1236 #[tokio::test]
1237 async fn read_files_path_escape_reported_per_file() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let tools = make_tools(dir.path());
1240 let result = tools
1241 .execute("read_files", json!({"paths": ["../../etc/passwd"]}))
1242 .await;
1243 assert!(result.contains("### [../../etc/passwd]"));
1244 assert!(result.contains("escape"));
1245 }
1246
1247 #[test]
1250 fn resolve_absolute_path_inside_workdir() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let tools = make_tools(dir.path());
1253 let abs = tools.ctx.workdir.join("inside.txt");
1257 let result = tools.resolve(abs.to_str().unwrap()).unwrap();
1258 assert_eq!(result, abs);
1259 }
1260
1261 #[test]
1262 fn resolve_rejects_absolute_path_outside_workdir() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let tools = make_tools(dir.path());
1265 let result = tools.resolve("/etc/passwd");
1266 assert!(result.is_err());
1267 }
1268
1269 #[tokio::test]
1272 async fn read_file_path_escape_rejected() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let tools = make_tools(dir.path());
1275 let result = tools
1276 .execute("read_file", json!({"path": "../../etc/passwd"}))
1277 .await;
1278 assert!(result.contains("[error]"));
1279 assert!(result.contains("escape"));
1280 }
1281
1282 #[tokio::test]
1283 async fn write_file_path_escape_rejected() {
1284 let dir = tempfile::tempdir().unwrap();
1285 let tools = make_tools(dir.path());
1286 let result = tools
1287 .execute(
1288 "write_file",
1289 json!({"path": "../../evil.txt", "content": "x"}),
1290 )
1291 .await;
1292 assert!(result.contains("[error]"));
1293 assert!(result.contains("escape"));
1294 }
1295
1296 #[tokio::test]
1297 async fn edit_file_path_escape_rejected() {
1298 let dir = tempfile::tempdir().unwrap();
1299 let tools = make_tools(dir.path());
1300 let result = tools
1301 .execute(
1302 "edit_file",
1303 json!({"path": "../../evil.txt", "old_str": "a", "new_str": "b"}),
1304 )
1305 .await;
1306 assert!(result.contains("[error]"));
1307 assert!(result.contains("escape"));
1308 }
1309
1310 #[tokio::test]
1311 async fn list_dir_path_escape_rejected() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let tools = make_tools(dir.path());
1314 let result = tools.execute("list_dir", json!({"path": "../../"})).await;
1315 assert!(result.contains("[error]"));
1316 assert!(result.contains("escape"));
1317 }
1318
1319 #[tokio::test]
1322 async fn write_file_fails_when_path_is_a_directory() {
1323 let dir = tempfile::tempdir().unwrap();
1324 let tools = make_tools(dir.path());
1325 fs::create_dir(dir.path().join("adir")).unwrap();
1326
1327 let result = tools
1328 .execute("write_file", json!({"path": "adir", "content": "x"}))
1329 .await;
1330 assert!(result.contains("[error]"));
1331 assert!(result.contains("Failed to write"));
1332 }
1333
1334 #[tokio::test]
1335 async fn write_file_parent_dir_creation_fails_when_blocked_by_file() {
1336 let dir = tempfile::tempdir().unwrap();
1337 let tools = make_tools(dir.path());
1338 fs::write(dir.path().join("blocker"), "im a file").unwrap();
1340
1341 let result = tools
1342 .execute(
1343 "write_file",
1344 json!({"path": "blocker/nested.txt", "content": "x"}),
1345 )
1346 .await;
1347 assert!(result.contains("[error]"));
1348 assert!(result.contains("Failed to create directories"));
1349 }
1350
1351 #[tokio::test]
1352 async fn read_file_fails_when_path_is_a_directory() {
1353 let dir = tempfile::tempdir().unwrap();
1354 let tools = make_tools(dir.path());
1355 fs::create_dir(dir.path().join("adir")).unwrap();
1356
1357 let result = tools.execute("read_file", json!({"path": "adir"})).await;
1358 assert!(result.contains("[error]"));
1359 assert!(result.contains("Failed to read"));
1360 }
1361
1362 #[tokio::test]
1363 async fn list_dir_fails_when_path_is_a_file() {
1364 let dir = tempfile::tempdir().unwrap();
1365 let tools = make_tools(dir.path());
1366 fs::write(dir.path().join("afile.txt"), "content").unwrap();
1367
1368 let result = tools
1369 .execute("list_dir", json!({"path": "afile.txt"}))
1370 .await;
1371 assert!(result.contains("[error]"));
1372 assert!(result.contains("Failed to read directory"));
1373 }
1374
1375 #[tokio::test]
1376 async fn edit_file_write_failure_after_successful_match() {
1377 let dir = tempfile::tempdir().unwrap();
1378 let tools = make_tools(dir.path());
1379 let file_path = dir.path().join("ro.txt");
1380 fs::write(&file_path, "hello world").unwrap();
1381
1382 let original = fs::metadata(&file_path).unwrap().permissions();
1388 let mut perms = original.clone();
1389 perms.set_readonly(true);
1390 fs::set_permissions(&file_path, perms).unwrap();
1391
1392 let result = tools
1393 .execute(
1394 "edit_file",
1395 json!({"path": "ro.txt", "old_str": "hello", "new_str": "goodbye"}),
1396 )
1397 .await;
1398
1399 fs::set_permissions(&file_path, original).unwrap();
1404
1405 assert!(result.contains("[error]"));
1406 assert!(result.contains("Failed to write"));
1407 }
1408
1409 #[tokio::test]
1410 async fn shell_echo_command() {
1411 let dir = tempfile::tempdir().unwrap();
1412 let tools = make_tools(dir.path());
1413 let result = tools
1414 .execute("shell", json!({"command": "echo hello"}))
1415 .await;
1416 assert!(result.trim().contains("hello"));
1417 }
1418
1419 #[tokio::test]
1420 async fn bash_alias_works() {
1421 let dir = tempfile::tempdir().unwrap();
1422 let tools = make_tools(dir.path());
1423 let result = tools
1424 .execute("bash", json!({"command": "echo alias_test"}))
1425 .await;
1426 assert!(result.contains("alias_test"));
1427 }
1428
1429 #[tokio::test]
1430 async fn shell_missing_command_arg() {
1431 let dir = tempfile::tempdir().unwrap();
1432 let tools = make_tools(dir.path());
1433 let result = tools.execute("shell", json!({})).await;
1434 assert!(result.contains("missing 'command'"));
1435 }
1436
1437 struct RedirectExecutor;
1440 impl ShellExecutor for RedirectExecutor {
1441 fn build_command(
1442 &self,
1443 shell: &str,
1444 flag: &str,
1445 _command: &str,
1446 workdir: &Path,
1447 ) -> Command {
1448 let mut c = Command::new(shell);
1449 c.arg(flag).arg("echo SANDBOXED").current_dir(workdir);
1450 c
1451 }
1452 }
1453
1454 #[tokio::test]
1455 async fn shell_routes_through_executor_when_present() {
1456 let dir = tempfile::tempdir().unwrap();
1457 let tools = BuiltinTools::new(ToolContext::new(dir.path().to_path_buf()))
1458 .with_shell_executor(Arc::new(RedirectExecutor));
1459 let result = tools
1461 .execute("shell", json!({"command": "echo host"}))
1462 .await;
1463 assert!(result.contains("SANDBOXED"), "got: {result}");
1464 assert!(!result.contains("host"));
1465 }
1466
1467 #[tokio::test]
1468 async fn shell_failing_command() {
1469 let dir = tempfile::tempdir().unwrap();
1470 let tools = make_tools(dir.path());
1471 let result = tools.execute("shell", json!({"command": "false"})).await;
1472 assert!(result.contains("[exit code"));
1473 }
1474
1475 #[tokio::test]
1476 async fn shell_successful_command_with_no_output() {
1477 let dir = tempfile::tempdir().unwrap();
1478 let tools = make_tools(dir.path());
1479 let result = tools.execute("shell", json!({"command": "true"})).await;
1480 assert_eq!(result, "(command succeeded with no output)");
1481 }
1482
1483 #[test]
1490 fn format_command_output_non_zero_exit_reports_stdout_and_stderr() {
1491 let result = BuiltinTools::format_command_output(b"out-line\n", b"err-line\n", false, 1);
1492 assert!(result.contains("[exit code 1]"));
1493 assert!(result.contains("stdout:"));
1494 assert!(result.contains("out-line"));
1495 assert!(result.contains("stderr:"));
1496 assert!(result.contains("err-line"));
1497 }
1498
1499 #[test]
1500 fn format_command_output_non_zero_exit_omits_empty_streams() {
1501 let result = BuiltinTools::format_command_output(b" \n", b"", false, 2);
1504 assert_eq!(result, "[exit code 2]\n");
1505 }
1506
1507 #[test]
1508 fn format_command_output_success_with_output_returns_stdout() {
1509 let result = BuiltinTools::format_command_output(b"hello\n", b"", true, 0);
1510 assert_eq!(result, "hello\n");
1511 }
1512
1513 #[test]
1514 fn format_command_output_success_no_output() {
1515 let result = BuiltinTools::format_command_output(b" ", b"noise", true, 0);
1516 assert_eq!(result, "(command succeeded with no output)");
1517 }
1518
1519 use crate::exec::{
1522 Captured, MAX_CAPTURE_BYTES, MAX_READ_FILE_BYTES, cap_file_content, capture_capped,
1523 capture_note,
1524 };
1525
1526 struct CountingReader {
1529 remaining: usize,
1530 chunk: usize,
1531 reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1532 }
1533
1534 impl tokio::io::AsyncRead for CountingReader {
1535 fn poll_read(
1536 mut self: std::pin::Pin<&mut Self>,
1537 _cx: &mut std::task::Context<'_>,
1538 buf: &mut tokio::io::ReadBuf<'_>,
1539 ) -> std::task::Poll<std::io::Result<()>> {
1540 self.reads
1541 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1542 if self.remaining == 0 {
1543 return std::task::Poll::Ready(Ok(()));
1544 }
1545 let n = self.chunk.min(self.remaining).min(buf.remaining());
1546 buf.put_slice(&vec![b'x'; n]);
1547 self.remaining -= n;
1548 std::task::Poll::Ready(Ok(()))
1549 }
1550 }
1551
1552 #[tokio::test]
1553 async fn capture_capped_keeps_the_cap_and_counts_what_it_dropped() {
1554 let payload = vec![b'a'; 5000];
1555 let mut source = &payload[..];
1556 let got = capture_capped(&mut source, 100).await;
1557 assert_eq!(got.kept.len(), 100);
1558 assert_eq!(got.total, 5000);
1559 }
1560
1561 #[tokio::test]
1562 async fn capture_capped_keeps_everything_under_the_cap() {
1563 let payload = [b'a'; 40];
1564 let mut source = &payload[..];
1565 let got = capture_capped(&mut source, 100).await;
1566 assert_eq!(got.kept.len(), 40);
1567 assert_eq!(got.total, 40);
1568 }
1569
1570 #[tokio::test]
1575 async fn capture_capped_drains_past_the_cap_so_the_child_never_blocks() {
1576 let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1577 let mut source = CountingReader {
1578 remaining: 10_000,
1579 chunk: 1_000,
1580 reads: reads.clone(),
1581 };
1582 let got = capture_capped(&mut source, 100).await;
1583 assert_eq!(got.total, 10_000, "the tail was not read");
1584 assert_eq!(got.kept.len(), 100);
1585 assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 11);
1587 }
1588
1589 #[tokio::test]
1593 async fn capture_capped_treats_a_read_error_as_the_end_of_the_output() {
1594 struct FailsAfterOne(bool);
1595 impl tokio::io::AsyncRead for FailsAfterOne {
1596 fn poll_read(
1597 mut self: std::pin::Pin<&mut Self>,
1598 _cx: &mut std::task::Context<'_>,
1599 buf: &mut tokio::io::ReadBuf<'_>,
1600 ) -> std::task::Poll<std::io::Result<()>> {
1601 if self.0 {
1602 return std::task::Poll::Ready(Err(std::io::Error::other("pipe broke")));
1603 }
1604 self.0 = true;
1605 buf.put_slice(b"partial");
1606 std::task::Poll::Ready(Ok(()))
1607 }
1608 }
1609 let mut source = FailsAfterOne(false);
1610 let got = capture_capped(&mut source, 100).await;
1611 assert_eq!(got.kept, b"partial");
1612 assert_eq!(got.total, 7);
1613 }
1614
1615 fn captured(kept: usize, total: u64) -> Captured {
1616 Captured {
1617 kept: vec![b'x'; kept],
1618 total,
1619 }
1620 }
1621
1622 #[test]
1623 fn capture_note_is_silent_when_nothing_was_dropped() {
1624 assert!(capture_note(&captured(10, 10), &captured(0, 0), 10).is_none());
1625 }
1626
1627 #[test]
1630 fn a_file_under_the_cap_comes_back_whole() {
1631 let content = "hello".repeat(10);
1632 assert_eq!(cap_file_content(&content, 1024), content);
1633 }
1634
1635 #[test]
1636 fn a_file_over_the_cap_is_truncated_and_says_so() {
1637 let content = "x".repeat(5000);
1642 let capped = cap_file_content(&content, 1000);
1643 assert!(capped.starts_with(&"x".repeat(1000)));
1644 assert!(capped.contains("[truncated]"), "{capped}");
1645 assert!(
1646 capped.contains("5000"),
1647 "the real size is the useful part: {capped}"
1648 );
1649 }
1650
1651 #[test]
1652 fn truncation_lands_on_a_char_boundary() {
1653 let content = "é".repeat(100);
1656 let capped = cap_file_content(&content, 51);
1657 assert!(capped.starts_with("é"));
1658 assert!(capped.contains("[truncated]"));
1659 }
1660
1661 #[tokio::test]
1662 async fn read_file_applies_the_cap() {
1663 let dir = tempfile::tempdir().unwrap();
1664 std::fs::write(
1665 dir.path().join("big.txt"),
1666 "y".repeat(MAX_READ_FILE_BYTES + 4096),
1667 )
1668 .unwrap();
1669 let tools = make_tools(dir.path());
1670 let out = tools.read_file(&json!({ "path": "big.txt" })).await;
1671 assert!(out.contains("[truncated]"), "an unbounded read is the bug");
1672 assert!(out.len() < MAX_READ_FILE_BYTES + 4096);
1673 }
1674
1675 #[test]
1676 fn capture_note_names_whichever_stream_overran() {
1677 let over = captured(10, 5_000);
1678 let fine = captured(10, 10);
1679 let stdout_only = capture_note(&over, &fine, 10).expect("stdout overran");
1680 assert!(stdout_only.contains("stdout exceeded"), "{stdout_only}");
1681 let stderr_only = capture_note(&fine, &over, 10).expect("stderr overran");
1682 assert!(stderr_only.contains("stderr exceeded"), "{stderr_only}");
1683 let both = capture_note(&over, &over, 10).expect("both overran");
1684 assert!(both.contains("stdout and stderr exceeded"), "{both}");
1685 assert!(both.contains("10000 bytes"), "{both}");
1687 }
1688
1689 #[tokio::test]
1696 async fn a_command_that_outruns_the_cap_is_truncated_and_says_so() {
1697 let dir = tempfile::tempdir().unwrap();
1698 let tools = make_tools(dir.path());
1699 let result = tools
1700 .shell_with_limits(
1701 &json!({"command": "echo hello"}),
1702 Duration::from_secs(30),
1703 4,
1704 )
1705 .await;
1706 assert!(result.contains("[truncated]"), "{result}");
1707 assert!(result.contains("hell"), "{result}");
1708 assert!(!result.contains("[timed out]"), "{result}");
1709 }
1710
1711 #[tokio::test]
1715 async fn a_command_within_the_cap_gets_no_truncation_note() {
1716 let dir = tempfile::tempdir().unwrap();
1717 let tools = make_tools(dir.path());
1718 let result = tools
1719 .shell_with_limits(
1720 &json!({"command": "echo hello"}),
1721 Duration::from_secs(30),
1722 MAX_CAPTURE_BYTES,
1723 )
1724 .await;
1725 assert!(result.contains("hello"), "{result}");
1726 assert!(!result.contains("[truncated]"), "{result}");
1727 }
1728
1729 #[cfg(unix)]
1732 #[tokio::test]
1733 async fn a_command_that_floods_stdout_is_truncated_rather_than_timing_out() {
1734 let dir = tempfile::tempdir().unwrap();
1735 let tools = make_tools(dir.path());
1736 let result = tools
1737 .shell_with_timeout(
1738 &json!({"command": "head -c 3000000 /dev/zero | tr '\\0' 'x'"}),
1739 Duration::from_secs(30),
1740 )
1741 .await;
1742 assert!(result.contains("[truncated]"));
1743 assert!(!result.contains("[timed out]"));
1744 let ceiling = MAX_CAPTURE_BYTES + 1000;
1746 assert!(result.len() < ceiling);
1747 }
1748
1749 #[tokio::test]
1750 async fn shell_with_timeout_fires_on_slow_command() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let tools = make_tools(dir.path());
1753 let result = tools
1754 .shell_with_timeout(&json!({"command": "sleep 5"}), Duration::from_millis(100))
1755 .await;
1756 assert!(result.contains("[timed out]"));
1757 }
1758
1759 #[cfg(unix)]
1766 #[tokio::test]
1767 async fn a_timed_out_command_kills_its_grandchildren() {
1768 let dir = tempfile::tempdir().unwrap();
1769 let marker = dir.path().join("survived");
1770 let tools = make_tools(dir.path());
1771
1772 let cmd = format!("( sleep 2; touch {} ) & sleep 30", marker.display());
1777 let result = tools
1778 .shell_with_timeout(&json!({ "command": cmd }), Duration::from_millis(100))
1779 .await;
1780 assert!(result.contains("[timed out]"), "got: {result}");
1781
1782 tokio::time::sleep(Duration::from_secs(3)).await;
1784 assert!(
1785 !marker.exists(),
1786 "the grandchild outlived the command that started it"
1787 );
1788 }
1789
1790 #[tokio::test]
1791 async fn shell_spawn_failure_when_workdir_missing() {
1792 let tools = make_tools(std::path::Path::new(
1797 "/definitely/does/not/exist/leviath-test",
1798 ));
1799 let result = tools.execute("shell", json!({"command": "echo hi"})).await;
1800 assert!(result.contains("[error]"));
1801 assert!(result.contains("Failed to spawn shell"));
1802 }
1803
1804 #[test]
1807 fn tool_context_new_canonicalizes() {
1808 let dir = std::env::temp_dir();
1809 let ctx = ToolContext::new(dir.clone());
1810 assert!(ctx.workdir.is_absolute());
1812 }
1813
1814 #[test]
1815 fn tool_context_new_with_nonexistent_dir() {
1816 let ctx = ToolContext::new(PathBuf::from("/nonexistent/path/unlikely"));
1817 assert_eq!(ctx.workdir, PathBuf::from("/nonexistent/path/unlikely"));
1819 }
1820
1821 #[test]
1828 fn detect_shell_returns_cmd_exe_on_windows() {
1829 let (shell, flag) = BuiltinTools::detect_shell_for("windows", None, &|_| true);
1830 assert_eq!(shell, "cmd.exe");
1831 assert_eq!(flag, "/C");
1832
1833 let (shell, _) =
1834 BuiltinTools::detect_shell_for("windows", Some("/usr/bin/bash".to_string()), &|_| true);
1835 assert_eq!(shell, "cmd.exe", "$SHELL is not consulted on Windows");
1836 }
1837
1838 #[test]
1839 fn detect_shell_returns_valid_shell() {
1840 let (shell, flag) = BuiltinTools::detect_shell();
1844 assert!(!shell.is_empty());
1845 assert!(!flag.is_empty());
1846 #[cfg(not(windows))]
1847 assert_eq!(flag, "-c");
1848 }
1849
1850 #[test]
1860 fn detect_shell_queries_the_real_filesystem_for_an_unrecognized_shell() {
1861 let (shell, flag) = BuiltinTools::detect_shell_for(
1862 "linux",
1863 Some("/opt/not-a-recognized-shell".to_string()),
1864 &BuiltinTools::shell_path_exists,
1865 );
1866 assert_eq!(flag, "-c");
1867 assert!(
1868 [
1869 "/bin/bash",
1870 "/usr/bin/bash",
1871 "/bin/zsh",
1872 "/usr/bin/zsh",
1873 "/bin/sh",
1874 "sh",
1875 ]
1876 .contains(&shell),
1877 "unexpected shell: {shell}"
1878 );
1879 }
1880
1881 #[test]
1884 fn detect_shell_for_returns_zsh_from_env() {
1885 let (shell, flag) =
1887 BuiltinTools::detect_shell_for("linux", Some("/usr/local/bin/zsh".to_string()), &|s| {
1888 s == "/usr/local/bin/zsh"
1889 });
1890 assert_eq!(shell, "/usr/local/bin/zsh");
1891 assert_eq!(flag, "-c");
1892 }
1893
1894 #[test]
1895 fn detect_shell_for_returns_bash_from_env() {
1896 let (shell, flag) = BuiltinTools::detect_shell_for(
1897 "macos",
1898 Some("/usr/local/bin/bash".to_string()),
1899 &|s| s == "/usr/local/bin/bash",
1900 );
1901 assert_eq!(shell, "/usr/local/bin/bash");
1902 assert_eq!(flag, "-c");
1903 }
1904
1905 #[test]
1906 fn detect_shell_for_returns_sh_from_env() {
1907 let (shell, flag) =
1910 BuiltinTools::detect_shell_for("freebsd", Some("/usr/bin/sh".to_string()), &|s| {
1911 s == "/usr/bin/sh"
1912 });
1913 assert_eq!(shell, "/usr/bin/sh");
1914 assert_eq!(flag, "-c");
1915 }
1916
1917 #[test]
1918 fn detect_shell_for_falls_back_when_env_shell_is_missing() {
1919 let (shell, flag) =
1924 BuiltinTools::detect_shell_for("linux", Some("/bin/zsh".to_string()), &|s| {
1925 s == "/bin/sh"
1926 });
1927 assert_eq!(shell, "/bin/sh");
1928 assert_eq!(flag, "-c");
1929 }
1930
1931 #[test]
1932 fn detect_shell_for_falls_through_when_env_unrecognized() {
1933 let (shell, flag) =
1935 BuiltinTools::detect_shell_for("linux", Some("/opt/fish".to_string()), &|s| {
1936 s == "/bin/bash"
1937 });
1938 assert_eq!(shell, "/bin/bash");
1939 assert_eq!(flag, "-c");
1940 }
1941
1942 #[test]
1943 fn detect_shell_for_skips_missing_candidates_and_finds_zsh() {
1944 let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|s| s == "/bin/zsh");
1946 assert_eq!(shell, "/bin/zsh");
1947 assert_eq!(flag, "-c");
1948 }
1949
1950 #[test]
1951 fn detect_shell_for_returns_last_resort_when_nothing_exists() {
1952 let (shell, flag) = BuiltinTools::detect_shell_for("linux", None, &|_| false);
1953 assert_eq!(shell, "sh");
1954 assert_eq!(flag, "-c");
1955 }
1956
1957 #[tokio::test]
1958 async fn concurrent_edits_same_file_serialize_no_lost_update() {
1959 let dir = tempfile::tempdir().unwrap();
1963 std::fs::write(dir.path().join("f.txt"), "A\nB\n").unwrap();
1964 let tools = std::sync::Arc::new(make_tools(dir.path()));
1965
1966 let t1 = {
1967 let t = tools.clone();
1968 tokio::spawn(async move {
1969 t.execute(
1970 "edit_file",
1971 json!({"path": "f.txt", "old_str": "A", "new_str": "A1"}),
1972 )
1973 .await
1974 })
1975 };
1976 let t2 = {
1977 let t = tools.clone();
1978 tokio::spawn(async move {
1979 t.execute(
1980 "edit_file",
1981 json!({"path": "f.txt", "old_str": "B", "new_str": "B2"}),
1982 )
1983 .await
1984 })
1985 };
1986 let (r1, r2) = tokio::join!(t1, t2);
1987 assert!(!r1.unwrap().starts_with("[error]"));
1988 assert!(!r2.unwrap().starts_with("[error]"));
1989
1990 let final_content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
1991 assert_eq!(
1992 final_content, "A1\nB2\n",
1993 "both concurrent edits must apply (no lost update)"
1994 );
1995 }
1996
1997 #[tokio::test]
1998 async fn concurrent_writes_different_files_both_succeed() {
1999 let dir = tempfile::tempdir().unwrap();
2001 let tools = std::sync::Arc::new(make_tools(dir.path()));
2002
2003 let a = {
2004 let t = tools.clone();
2005 tokio::spawn(async move {
2006 t.execute("write_file", json!({"path": "a.txt", "content": "AAA"}))
2007 .await
2008 })
2009 };
2010 let b = {
2011 let t = tools.clone();
2012 tokio::spawn(async move {
2013 t.execute("write_file", json!({"path": "b.txt", "content": "BBB"}))
2014 .await
2015 })
2016 };
2017 let (ra, rb) = tokio::join!(a, b);
2018 assert!(!ra.unwrap().starts_with("[error]"));
2019 assert!(!rb.unwrap().starts_with("[error]"));
2020 assert_eq!(
2021 std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
2022 "AAA"
2023 );
2024 assert_eq!(
2025 std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
2026 "BBB"
2027 );
2028 }
2029
2030 #[test]
2033 fn desktop_supports_all_capabilities() {
2034 let caps = PlatformCapabilities::desktop();
2035 assert!(caps.supports(ToolCapability::ProcessSpawn));
2036 assert!(caps.supports(ToolCapability::FileSystem));
2037 assert!(caps.supports(ToolCapability::Network));
2038 }
2039
2040 #[test]
2041 fn mobile_lacks_process_spawn() {
2042 let caps = PlatformCapabilities::mobile();
2043 assert!(!caps.supports(ToolCapability::ProcessSpawn));
2044 assert!(caps.supports(ToolCapability::FileSystem));
2045 assert!(caps.supports(ToolCapability::Network));
2046 }
2047
2048 #[test]
2049 fn current_matches_desktop_and_is_the_default() {
2050 assert_eq!(
2052 PlatformCapabilities::current(),
2053 PlatformCapabilities::desktop()
2054 );
2055 assert_eq!(
2056 PlatformCapabilities::default(),
2057 PlatformCapabilities::desktop()
2058 );
2059 }
2060
2061 #[test]
2062 fn satisfies_requires_all_and_empty_is_always_met() {
2063 let caps = PlatformCapabilities::mobile();
2064 assert!(caps.satisfies(&[]));
2065 assert!(caps.satisfies(&[ToolCapability::FileSystem]));
2066 assert!(!caps.satisfies(&[ToolCapability::ProcessSpawn]));
2067 assert!(!caps.satisfies(&[ToolCapability::FileSystem, ToolCapability::ProcessSpawn]));
2069 }
2070
2071 #[test]
2072 fn from_capabilities_builds_explicit_set() {
2073 let caps = PlatformCapabilities::from_capabilities([ToolCapability::Network]);
2074 assert!(caps.supports(ToolCapability::Network));
2075 assert!(!caps.supports(ToolCapability::FileSystem));
2076 }
2077
2078 #[test]
2079 fn tool_required_capabilities_by_name() {
2080 assert_eq!(
2081 tool_required_capabilities("shell"),
2082 &[ToolCapability::ProcessSpawn]
2083 );
2084 assert_eq!(
2085 tool_required_capabilities("read_file"),
2086 &[ToolCapability::FileSystem]
2087 );
2088 assert!(tool_required_capabilities("context_write").is_empty());
2090 assert!(tool_required_capabilities("present_for_review").is_empty());
2091 assert!(tool_required_capabilities("unknown_tool").is_empty());
2092 }
2093
2094 #[test]
2095 fn mobile_tool_defs_omit_shell_but_keep_the_rest() {
2096 let dir = std::env::temp_dir();
2097 let tools = make_mobile_tools(&dir);
2098 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
2099 assert!(!names.contains(&"shell".to_string()));
2100 assert_eq!(tools.tool_defs().len(), 19);
2102 assert!(names.contains(&"read_file".to_string()));
2103 assert!(names.contains(&"context_write".to_string()));
2104 assert!(names.contains(&"present_for_review".to_string()));
2105 }
2106
2107 #[test]
2108 fn desktop_tool_defs_include_shell() {
2109 let dir = std::env::temp_dir();
2110 let tools = make_tools(&dir);
2111 let names: Vec<String> = tools.tool_defs().iter().map(|t| t.name.clone()).collect();
2112 assert!(names.contains(&"shell".to_string()));
2113 }
2114
2115 #[test]
2116 fn mobile_names_omit_shell_and_bash_alias() {
2117 let dir = std::env::temp_dir();
2118 let tools = make_mobile_tools(&dir);
2119 let names = tools.names();
2120 assert!(!names.contains(&"shell".to_string()));
2121 assert!(!names.contains(&"bash".to_string()));
2122 assert!(names.contains(&"read_file".to_string()));
2124 assert!(names.contains(&"context_write".to_string()));
2125 }
2126
2127 #[test]
2128 fn desktop_names_include_shell_and_bash_alias() {
2129 let dir = std::env::temp_dir();
2130 let tools = make_tools(&dir);
2131 let names = tools.names();
2132 assert!(names.contains(&"shell".to_string()));
2133 assert!(names.contains(&"bash".to_string()));
2134 }
2135
2136 #[tokio::test]
2137 async fn mobile_execute_shell_is_rejected() {
2138 let dir = tempfile::tempdir().unwrap();
2139 let tools = make_mobile_tools(dir.path());
2140 let out = tools.execute("shell", json!({"command": "echo hi"})).await;
2141 assert!(out.contains("not available on this platform"), "got: {out}");
2142 let out = tools.execute("bash", json!({"command": "echo hi"})).await;
2144 assert!(out.contains("not available on this platform"), "got: {out}");
2145 }
2146
2147 #[tokio::test]
2148 async fn mobile_execute_file_tool_still_works() {
2149 let dir = tempfile::tempdir().unwrap();
2150 let tools = make_mobile_tools(dir.path());
2151 let out = tools
2152 .execute("write_file", json!({"path": "x.txt", "content": "hi"}))
2153 .await;
2154 assert!(!out.starts_with("[error]"), "got: {out}");
2155 assert_eq!(
2156 std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
2157 "hi"
2158 );
2159 }
2160
2161 #[tokio::test]
2168 async fn write_file_to_the_null_device_is_allowed() {
2169 let dir = tempfile::tempdir().unwrap();
2170 let tools = make_tools(dir.path());
2171 let result = tools
2172 .execute(
2173 "write_file",
2174 json!({"path": "/dev/null", "content": "thrown away"}),
2175 )
2176 .await;
2177 assert!(
2178 !result.contains("escape"),
2179 "the null device is not an escape: {result}"
2180 );
2181 }
2182
2183 #[tokio::test]
2184 async fn read_file_from_the_null_device_is_allowed() {
2185 let dir = tempfile::tempdir().unwrap();
2186 let tools = make_tools(dir.path());
2187 let result = tools
2188 .execute("read_file", json!({"path": "/dev/null"}))
2189 .await;
2190 assert!(
2191 !result.contains("escape"),
2192 "the null device is not an escape: {result}"
2193 );
2194 }
2195
2196 #[tokio::test]
2200 async fn a_real_outside_path_is_still_refused() {
2201 let dir = tempfile::tempdir().unwrap();
2202 let tools = make_tools(dir.path());
2203 let result = tools
2204 .execute("write_file", json!({"path": "../out.txt", "content": "x"}))
2205 .await;
2206 assert!(result.contains("escape"), "got: {result}");
2207 }
2208
2209 #[test]
2220 fn the_daemons_own_streams_are_not_null_devices() {
2221 assert!(is_null_device("/dev/null"), "the sink is a sink");
2222 assert!(is_null_device("NUL"), "and so is the Windows spelling");
2223 assert!(is_null_device("nul"), "case does not decide it");
2224 assert!(!is_null_device("/dev/stdout"));
2225 assert!(!is_null_device("/dev/stderr"));
2226 assert!(
2227 !is_null_device("notes.md"),
2228 "an ordinary path is not a sink"
2229 );
2230 }
2231
2232 #[tokio::test]
2236 async fn an_escape_refusal_says_where_to_write_instead() {
2237 let dir = tempfile::tempdir().unwrap();
2238 let tools = make_tools(dir.path());
2239 let result = tools
2240 .execute("write_file", json!({"path": "../out.txt", "content": "x"}))
2241 .await;
2242 let leaf = dir
2247 .path()
2248 .file_name()
2249 .expect("a temp dir has a name")
2250 .to_string_lossy()
2251 .to_string();
2252 assert!(result.contains(&leaf), "names the workspace root: {result}");
2253 assert!(
2254 result.contains("inside the workspace"),
2255 "says what to do instead: {result}"
2256 );
2257 }
2258}