1use std::collections::BTreeMap;
17use std::path::{Component, Path, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20
21use leviath_core::floor_char_boundary;
22use leviath_scripting::ScriptHost;
23use leviath_tools::ShellExecutor;
24use tokio::process::Command as TokioCommand;
25
26use crate::config::{ScriptPermission, ScriptToolPermissions, ToolPolicy};
27use crate::daemon::sandbox_manager::SandboxManager;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ScriptAllow {
34 pub http_get: bool,
36 pub http_post: bool,
38 pub shell: bool,
40 pub read_file: bool,
42 pub write_file: bool,
44 pub env_var: bool,
46}
47
48pub fn resolve_script_permissions(
63 perms: &ScriptToolPermissions,
64 resolve_builtin: &dyn Fn(&str) -> ToolPolicy,
65) -> ScriptAllow {
66 let net = |p: ScriptPermission| match p {
67 ScriptPermission::Allow | ScriptPermission::Inherit => true,
68 ScriptPermission::Deny => false,
69 };
70 let filelike = |p: ScriptPermission, builtin: &str| match p {
71 ScriptPermission::Allow => true,
72 ScriptPermission::Deny => false,
73 ScriptPermission::Inherit => resolve_builtin(builtin) == ToolPolicy::Allow,
74 };
75 ScriptAllow {
76 http_get: net(perms.http_get),
77 http_post: net(perms.http_post),
78 env_var: net(perms.env_var),
79 read_file: filelike(perms.read_file, "read_file"),
80 write_file: filelike(perms.write_file, "write_file"),
81 shell: filelike(perms.shell, "shell"),
82 }
83}
84
85fn parse_script_permission_str(s: &str) -> Option<ScriptPermission> {
90 match s {
91 "allow" => Some(ScriptPermission::Allow),
92 "deny" => Some(ScriptPermission::Deny),
93 "inherit" => Some(ScriptPermission::Inherit),
94 _ => None,
95 }
96}
97
98fn script_restrictiveness(p: ScriptPermission) -> u8 {
104 match p {
105 ScriptPermission::Allow => 0,
106 ScriptPermission::Inherit => 1,
107 ScriptPermission::Deny => 2,
108 }
109}
110
111pub fn effective_script_permissions(
125 global: &ScriptToolPermissions,
126 manifest_toml: &str,
127) -> ScriptToolPermissions {
128 let mut eff = global.clone();
129 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
135 return eff;
136 };
137 let Some(table) = value
138 .get("tool_script_permissions")
139 .and_then(|v| v.as_table())
140 else {
141 return eff;
142 };
143 let apply = |key: &str, slot: &mut ScriptPermission| {
146 if let Some(p) = table
147 .get(key)
148 .and_then(|v| v.as_str())
149 .and_then(parse_script_permission_str)
150 && script_restrictiveness(p) > script_restrictiveness(*slot)
151 {
152 *slot = p;
153 }
154 };
155 apply("http_get", &mut eff.http_get);
156 apply("http_post", &mut eff.http_post);
157 apply("shell", &mut eff.shell);
158 apply("read_file", &mut eff.read_file);
159 apply("write_file", &mut eff.write_file);
160 apply("env_var", &mut eff.env_var);
161 eff
162}
163
164pub trait ScriptIo: Send + Sync {
167 fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String>;
169 fn http_post(
171 &self,
172 url: &str,
173 body: &str,
174 headers: BTreeMap<String, String>,
175 ) -> Result<String, String>;
176 fn run_shell(&self, cmd: TokioCommand, timeout: Duration) -> Result<String, String>;
179 fn read_file(&self, path: &Path) -> Result<String, String>;
181 fn write_file(&self, path: &Path, content: &str) -> Result<String, String>;
184 fn env_var(&self, name: &str) -> Result<String, String>;
186}
187
188pub struct DaemonScriptHost {
191 allow: ScriptAllow,
192 workdir: PathBuf,
193 io: Arc<dyn ScriptIo>,
194 sandbox: Option<Arc<SandboxManager>>,
199 shell_timeout: Duration,
202 allow_local_network: bool,
206 allow_env_vars: Vec<String>,
209}
210
211impl DaemonScriptHost {
212 pub fn with_io(allow: ScriptAllow, workdir: PathBuf, io: Arc<dyn ScriptIo>) -> Self {
216 Self {
217 allow,
218 workdir,
219 io,
220 sandbox: None,
221 shell_timeout: Duration::from_secs(60),
222 allow_local_network: false,
223 allow_env_vars: Vec::new(),
224 }
225 }
226
227 pub fn with_local_network(mut self, allow: bool) -> Self {
230 self.allow_local_network = allow;
231 self
232 }
233
234 pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
237 self.allow_env_vars = names;
238 self
239 }
240
241 pub fn new(allow: ScriptAllow, workdir: PathBuf) -> Self {
243 Self::with_io(allow, workdir, Arc::new(RealScriptIo))
244 }
245
246 pub fn with_shell(
249 mut self,
250 sandbox: Option<Arc<SandboxManager>>,
251 shell_timeout: Duration,
252 ) -> Self {
253 self.sandbox = sandbox;
254 self.shell_timeout = shell_timeout;
255 self
256 }
257
258 fn resolve_in_workdir(&self, requested: &str) -> Result<PathBuf, String> {
262 Self::resolve_in(requested, &self.workdir, leviath_core::resolves_within)
263 }
264
265 fn resolve_in(
275 requested: &str,
276 workdir: &Path,
277 within: fn(&Path, &Path) -> bool,
278 ) -> Result<PathBuf, String> {
279 let raw = if Path::new(requested).is_absolute() {
280 PathBuf::from(requested)
281 } else {
282 workdir.join(requested)
283 };
284 let mut normalized = PathBuf::new();
285 for component in raw.components() {
286 match component {
287 Component::ParentDir => {
288 if !normalized.pop() {
289 return Err(format!("path '{requested}' escapes the working directory"));
290 }
291 }
292 c => normalized.push(c),
293 }
294 }
295 if !normalized.starts_with(workdir) {
296 return Err(format!(
297 "path '{requested}' would escape the working directory"
298 ));
299 }
300 if !within(&normalized, workdir) {
303 return Err(format!(
304 "path '{requested}' resolves outside the working directory through a symlink"
305 ));
306 }
307 Ok(normalized)
308 }
309}
310
311fn denied(func: &str) -> String {
314 format!("[denied] script host function '{func}' is denied by tool_script_permissions")
315}
316
317fn check_outbound(url: &str, allow_local: bool) -> Result<(), String> {
328 let parsed = url::Url::parse(url).map_err(|e| format!("[denied] invalid URL '{url}': {e}"))?;
329 leviath_core::check_url(&parsed, allow_local).map_err(|e| format!("[denied] {e}"))
330}
331
332impl ScriptHost for DaemonScriptHost {
333 fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
334 if !self.allow.http_get {
335 return Err(denied("http_get"));
336 }
337 check_outbound(url, self.allow_local_network)?;
338 self.io.http_get(url, headers)
339 }
340
341 fn http_post(
342 &self,
343 url: &str,
344 body: &str,
345 headers: BTreeMap<String, String>,
346 ) -> Result<String, String> {
347 if !self.allow.http_post {
348 return Err(denied("http_post"));
349 }
350 check_outbound(url, self.allow_local_network)?;
351 self.io.http_post(url, body, headers)
352 }
353
354 fn shell(&self, command: &str) -> Result<String, String> {
355 if !self.allow.shell {
356 return Err(denied("shell"));
357 }
358 let (shell, flag) = default_shell();
359 let cmd = match &self.sandbox {
363 Some(sb) => sb.build_command(shell, flag, command, &self.workdir),
364 None => host_shell_command(shell, flag, command, &self.workdir),
365 };
366 self.io.run_shell(cmd, self.shell_timeout)
367 }
368
369 fn read_file(&self, path: &str) -> Result<String, String> {
370 if !self.allow.read_file {
371 return Err(denied("read_file"));
372 }
373 let resolved = self.resolve_in_workdir(path)?;
374 self.io.read_file(&resolved)
375 }
376
377 fn write_file(&self, path: &str, content: &str) -> Result<String, String> {
378 if !self.allow.write_file {
379 return Err(denied("write_file"));
380 }
381 if !std::fs::metadata(&self.workdir).is_ok_and(|m| m.is_dir()) {
384 return Err(format!(
385 "workspace '{}' is no longer accessible",
386 self.workdir.display()
387 ));
388 }
389 let resolved = self.resolve_in_workdir(path)?;
390 self.io.write_file(&resolved, content)
391 }
392
393 fn env_var(&self, name: &str) -> Result<String, String> {
394 if !self.allow.env_var {
395 return Err(denied("env_var"));
396 }
397 if !leviath_core::script_env_allowed(name, &self.allow_env_vars) {
403 return Err(format!(
404 "[denied] '{name}' looks like a credential. Add it to `[security] \
405 allow_env_vars` in ~/.leviath/config.toml if this agent is meant \
406 to read it."
407 ));
408 }
409 self.io.env_var(name)
410 }
411}
412
413pub struct RealScriptIo;
419
420static HTTP_CLIENT: std::sync::LazyLock<reqwest::blocking::Client> =
434 std::sync::LazyLock::new(|| {
435 reqwest::blocking::Client::builder()
436 .timeout(Duration::from_secs(30))
437 .redirect(reqwest::redirect::Policy::custom(|attempt| {
443 if attempt.previous().len() >= 5 {
444 return attempt.error("too many redirects");
445 }
446 match leviath_core::check_url(attempt.url(), local_network_allowed()) {
447 Ok(()) => attempt.follow(),
448 Err(e) => attempt.error(format!("refused to follow redirect: {e}")),
449 }
450 }))
451 .build()
452 .expect("failed to build blocking reqwest client")
453 });
454
455fn error_chain(e: &dyn std::error::Error) -> String {
463 let mut parts = vec![e.to_string()];
464 let mut source = e.source();
465 while let Some(err) = source {
466 parts.push(err.to_string());
467 source = err.source();
468 }
469 parts.join(": ")
470}
471
472static ALLOW_LOCAL_REDIRECTS: std::sync::atomic::AtomicBool =
486 std::sync::atomic::AtomicBool::new(false);
487
488pub fn set_local_network_allowed(allow: bool) {
490 ALLOW_LOCAL_REDIRECTS.store(allow, std::sync::atomic::Ordering::Relaxed);
491}
492
493fn local_network_allowed() -> bool {
495 ALLOW_LOCAL_REDIRECTS.load(std::sync::atomic::Ordering::Relaxed)
496}
497
498impl RealScriptIo {
499 fn client() -> reqwest::blocking::Client {
502 HTTP_CLIENT.clone()
503 }
504
505 fn with_headers(
507 mut req: reqwest::blocking::RequestBuilder,
508 headers: BTreeMap<String, String>,
509 ) -> reqwest::blocking::RequestBuilder {
510 for (k, v) in headers {
511 req = req.header(k, v);
512 }
513 req
514 }
515
516 fn send(req: reqwest::blocking::RequestBuilder) -> Result<String, String> {
523 Self::send_capped(req, MAX_RESPONSE_BYTES)
524 }
525
526 fn send_capped(req: reqwest::blocking::RequestBuilder, max: u64) -> Result<String, String> {
529 let resp = req
530 .send()
531 .map_err(|e| format!("request failed: {}", error_chain(&e)))?;
532 let status = resp.status();
533 let content_type = resp
534 .headers()
535 .get(reqwest::header::CONTENT_TYPE)
536 .and_then(|v| v.to_str().ok())
537 .unwrap_or_default()
538 .to_string();
539 if is_binary_content_type(&content_type) {
540 let len = resp.content_length();
541 return Err(non_text_body_message(&content_type, len));
542 }
543 if let Some(msg) = oversized_body_message(resp.content_length(), max) {
554 return Err(msg);
555 }
556 let text = cap_script_io(resp.text().map_err(|e| format!("read body: {e}"))?);
557 if status.is_success() {
558 Ok(text)
559 } else {
560 Err(format!("http {status}: {text}"))
561 }
562 }
563}
564
565const BINARY_CONTENT_PREFIXES: &[&str] = &[
574 "image/",
575 "audio/",
576 "video/",
577 "font/",
578 "application/octet-stream",
579 "application/pdf",
580 "application/zip",
581 "application/gzip",
582 "application/x-tar",
583 "application/x-bzip",
584 "application/wasm",
585 "application/vnd.",
586 "application/msword",
587];
588
589fn is_binary_content_type(content_type: &str) -> bool {
591 let essence = content_type
593 .split(';')
594 .next()
595 .unwrap_or_default()
596 .trim()
597 .to_ascii_lowercase();
598 BINARY_CONTENT_PREFIXES
601 .iter()
602 .any(|prefix| essence.starts_with(prefix))
603}
604
605fn non_text_body_message(content_type: &str, len: Option<u64>) -> String {
608 let size = match len {
609 Some(bytes) => format!(", {} KB", bytes.div_ceil(1024)),
610 None => String::new(),
611 };
612 format!("non-text content ({content_type}{size}) - this tool returns text only")
613}
614
615const MAX_SCRIPT_IO_BYTES: usize = 900_000;
621
622const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
630
631fn oversized_body_message(content_length: Option<u64>, max: u64) -> Option<String> {
639 match content_length {
640 Some(len) if len > max => Some(format!(
641 "response declares {len} bytes, over the {max}-byte limit - \
642 fetch a more specific page"
643 )),
644 _ => None,
645 }
646}
647
648pub(crate) fn cap_script_io(mut s: String) -> String {
649 if s.len() > MAX_SCRIPT_IO_BYTES {
650 s.truncate(floor_char_boundary(&s, MAX_SCRIPT_IO_BYTES));
653 s.push_str("\n[...truncated by leviath: response exceeded 900 KB]");
654 }
655 s
656}
657
658impl ScriptIo for RealScriptIo {
659 fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
660 let client = Self::client();
661 Self::send(Self::with_headers(client.get(url), headers))
662 }
663
664 fn http_post(
665 &self,
666 url: &str,
667 body: &str,
668 headers: BTreeMap<String, String>,
669 ) -> Result<String, String> {
670 let client = Self::client();
671 Self::send(Self::with_headers(
672 client.post(url).body(body.to_string()),
673 headers,
674 ))
675 }
676
677 fn run_shell(&self, mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
678 let Ok(handle) = tokio::runtime::Handle::try_current() else {
686 return Err("shell is unavailable: no tokio runtime on this thread".to_string());
687 };
688 cmd.kill_on_drop(true);
694 leviath_tools::own_process_group(&mut cmd);
695 cmd.stdout(std::process::Stdio::piped())
698 .stderr(std::process::Stdio::piped());
699 handle.block_on(async move {
700 let run = async {
705 let child = cmd.spawn()?;
706 let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
707 child.wait_with_output().await
708 };
709 match tokio::time::timeout(timeout, run).await {
710 Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
711 &output.stdout,
712 &output.stderr,
713 ))),
714 Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
715 Err(_) => Err(format!(
716 "shell command timed out after {}s",
717 timeout.as_secs()
718 )),
719 }
720 })
721 }
722
723 fn read_file(&self, path: &Path) -> Result<String, String> {
724 std::fs::read_to_string(path)
725 .map(cap_script_io)
726 .map_err(|e| format!("read '{}': {e}", path.display()))
727 }
728
729 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
730 if let Some(parent) = path.parent() {
731 std::fs::create_dir_all(parent)
732 .map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
733 }
734 std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
735 Ok(format!(
736 "wrote {} bytes to {}",
737 content.len(),
738 path.display()
739 ))
740 }
741
742 fn env_var(&self, name: &str) -> Result<String, String> {
743 std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
744 }
745}
746
747pub(crate) fn default_shell() -> (&'static str, &'static str) {
754 #[cfg(windows)]
755 {
756 ("cmd.exe", "/C")
757 }
758 #[cfg(not(windows))]
759 {
760 ("/bin/sh", "-c")
761 }
762}
763
764pub(crate) fn host_shell_command(
767 shell: &str,
768 flag: &str,
769 command: &str,
770 workdir: &Path,
771) -> TokioCommand {
772 let mut c = TokioCommand::new(shell);
773 c.arg(flag).arg(command).current_dir(workdir);
774 c
775}
776
777pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
780 let mut out = String::from_utf8_lossy(stdout).into_owned();
781 let err = String::from_utf8_lossy(stderr);
782 if !err.trim().is_empty() {
783 out.push_str(&err);
784 }
785 out
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use std::sync::Mutex;
792
793 fn perms(all: ScriptPermission) -> ScriptToolPermissions {
796 ScriptToolPermissions {
797 http_get: all,
798 http_post: all,
799 shell: all,
800 read_file: all,
801 write_file: all,
802 env_var: all,
803 }
804 }
805
806 #[test]
807 fn resolve_allow_permits_everything() {
808 let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
809 assert_eq!(
810 a,
811 ScriptAllow {
812 http_get: true,
813 http_post: true,
814 shell: true,
815 read_file: true,
816 write_file: true,
817 env_var: true,
818 }
819 );
820 }
821
822 #[test]
823 fn resolve_deny_blocks_everything() {
824 let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
825 assert_eq!(
826 a,
827 ScriptAllow {
828 http_get: false,
829 http_post: false,
830 shell: false,
831 read_file: false,
832 write_file: false,
833 env_var: false,
834 }
835 );
836 }
837
838 #[test]
839 fn resolve_inherit_net_true_filelike_follows_builtin() {
840 let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
842 "read_file" => ToolPolicy::Allow,
843 _ => ToolPolicy::Ask,
844 });
845 assert!(a.http_get && a.http_post && a.env_var);
846 assert!(a.read_file, "read_file inherit → Allow");
847 assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
848 assert!(!a.shell, "shell inherit → Ask ⇒ denied");
849 }
850
851 #[test]
854 fn effective_perms_agent_tightens_per_field() {
855 let global = perms(ScriptPermission::Allow);
859 let manifest = "\
860 [tool_script_permissions]\n\
861 http_get = \"allow\"\n\
862 shell = \"deny\"\n\
863 write_file = \"inherit\"\n";
864 let eff = effective_script_permissions(&global, manifest);
865 assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
866 assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
867 assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
868 assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
869 assert_eq!(eff.read_file, ScriptPermission::Allow);
870 assert_eq!(eff.http_post, ScriptPermission::Allow);
871 }
872
873 #[test]
878 fn effective_perms_agent_cannot_loosen_global() {
879 let global = perms(ScriptPermission::Deny);
880 let manifest = "\
881 [tool_script_permissions]\n\
882 http_get = \"allow\"\n\
883 shell = \"allow\"\n\
884 env_var = \"inherit\"\n";
885 let eff = effective_script_permissions(&global, manifest);
886 assert_eq!(eff.http_get, ScriptPermission::Deny);
887 assert_eq!(eff.shell, ScriptPermission::Deny);
888 assert_eq!(eff.env_var, ScriptPermission::Deny);
889 }
890
891 #[test]
894 fn effective_perms_agent_cannot_promote_inherit_to_allow() {
895 let global = perms(ScriptPermission::Inherit);
896 let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
897 let eff = effective_script_permissions(&global, manifest);
898 assert_eq!(eff.shell, ScriptPermission::Inherit);
899 }
900
901 #[test]
902 fn effective_perms_absent_section_keeps_global() {
903 let global = perms(ScriptPermission::Deny);
904 let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
906 assert_eq!(eff.shell, ScriptPermission::Deny);
907 assert_eq!(eff.http_get, ScriptPermission::Deny);
908 }
909
910 #[test]
911 fn effective_perms_malformed_inputs_fall_back_to_global() {
912 let global = perms(ScriptPermission::Allow);
913 let eff = effective_script_permissions(&global, "not = valid = toml");
915 assert_eq!(eff.shell, ScriptPermission::Allow);
916 let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
918 assert_eq!(eff2.shell, ScriptPermission::Allow);
919 let eff3 =
921 effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
922 assert_eq!(eff3.shell, ScriptPermission::Allow);
923 }
924
925 struct RecordingIo {
928 calls: Mutex<Vec<String>>,
929 }
930 impl RecordingIo {
931 fn arc() -> Arc<RecordingIo> {
932 Arc::new(RecordingIo {
933 calls: Mutex::new(Vec::new()),
934 })
935 }
936 }
937 impl ScriptIo for RecordingIo {
938 fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
939 self.calls.lock().unwrap().push(format!("get:{url}"));
940 Ok("g".into())
941 }
942 fn http_post(
943 &self,
944 url: &str,
945 body: &str,
946 _h: BTreeMap<String, String>,
947 ) -> Result<String, String> {
948 self.calls
949 .lock()
950 .unwrap()
951 .push(format!("post:{url}:{body}"));
952 Ok("p".into())
953 }
954 fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
955 let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
957 self.calls.lock().unwrap().push(format!("shell:{prog}"));
958 Ok("s".into())
959 }
960 fn read_file(&self, path: &Path) -> Result<String, String> {
961 self.calls
962 .lock()
963 .unwrap()
964 .push(format!("read:{}", path.display()));
965 Ok("r".into())
966 }
967 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
968 self.calls
969 .lock()
970 .unwrap()
971 .push(format!("write:{}:{content}", path.display()));
972 Ok("w".into())
973 }
974 fn env_var(&self, name: &str) -> Result<String, String> {
975 self.calls.lock().unwrap().push(format!("env:{name}"));
976 Ok("e".into())
977 }
978 }
979
980 fn all_allowed() -> ScriptAllow {
981 ScriptAllow {
982 http_get: true,
983 http_post: true,
984 shell: true,
985 read_file: true,
986 write_file: true,
987 env_var: true,
988 }
989 }
990
991 fn none_allowed() -> ScriptAllow {
992 ScriptAllow {
993 http_get: false,
994 http_post: false,
995 shell: false,
996 read_file: false,
997 write_file: false,
998 env_var: false,
999 }
1000 }
1001
1002 #[test]
1003 fn script_write_refuses_a_deleted_workspace() {
1004 let dir = tempfile::tempdir().unwrap();
1007 let workdir = dir.path().join("gone");
1008 let io = RecordingIo::arc();
1009 let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1010 let err = host.write_file("out.txt", "body").unwrap_err();
1011 assert!(err.contains("no longer accessible"), "got: {err}");
1012 assert!(
1013 io.calls.lock().unwrap().is_empty(),
1014 "the io layer never ran"
1015 );
1016 std::fs::create_dir(&workdir).unwrap();
1018 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1019 }
1020
1021 const PUBLIC_URL: &str = "http://93.184.216.34/";
1025
1026 #[test]
1027 fn allowed_calls_delegate_to_io() {
1028 let io = RecordingIo::arc();
1029 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1030 assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1031 assert_eq!(
1032 host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1033 "p"
1034 );
1035 assert_eq!(host.shell("ls").unwrap(), "s");
1036 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1037 assert_eq!(host.env_var("HOME").unwrap(), "e");
1038 let calls = io.calls.lock().unwrap().clone();
1039 assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1040 assert!(calls.iter().any(|c| c.starts_with("post:")));
1041 assert!(calls.iter().any(|c| c.starts_with("shell:")));
1043 assert!(
1044 calls
1045 .iter()
1046 .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1047 );
1048 assert!(calls.contains(&"env:HOME".to_string()));
1049 }
1050
1051 #[test]
1055 fn outbound_check_blocks_local_targets_before_any_io() {
1056 let io = RecordingIo::arc();
1057 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1058 for url in [
1059 "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1061 "http://127.0.0.1:3000/api/agents",
1063 "http://192.168.1.1/",
1065 "file:///etc/passwd",
1067 ] {
1068 let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1069 assert!(err.starts_with("[denied]"), "{url} → {err}");
1070 let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1071 assert!(err.starts_with("[denied]"), "{url} → {err}");
1072 }
1073 let calls = io.calls.lock().unwrap().clone();
1074 assert!(
1075 calls.is_empty(),
1076 "a refused URL must never reach the I/O backend: {calls:?}"
1077 );
1078 }
1079
1080 #[test]
1085 fn env_var_refuses_credential_names_by_default() {
1086 let io = RecordingIo::arc();
1087 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1088 for name in [
1089 "ANTHROPIC_API_KEY",
1090 "OPENAI_API_KEY",
1091 "AWS_SECRET_ACCESS_KEY",
1092 "GITHUB_TOKEN",
1093 "LEVIATH_API_TOKEN",
1094 ] {
1095 let err = host.env_var(name).unwrap_err();
1096 assert!(err.starts_with("[denied]"), "{name} → {err}");
1097 assert!(err.contains("allow_env_vars"), "{name} → {err}");
1098 }
1099 assert!(
1100 io.calls.lock().unwrap().is_empty(),
1101 "a refused read must never reach the I/O backend"
1102 );
1103 }
1104
1105 #[test]
1108 fn env_var_allows_ordinary_names() {
1109 let io = RecordingIo::arc();
1110 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1111 assert_eq!(host.env_var("PATH").unwrap(), "e");
1112 assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1113 }
1114
1115 #[test]
1118 fn env_var_allowlist_permits_exactly_the_named_variable() {
1119 let io = RecordingIo::arc();
1120 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1121 .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1122 assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1123 assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1124 }
1125
1126 #[test]
1129 fn outbound_check_rejects_unparseable_urls() {
1130 let io = RecordingIo::arc();
1131 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1132 let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1133 assert!(err.contains("invalid URL"), "{err}");
1134 assert!(io.calls.lock().unwrap().is_empty());
1135 }
1136
1137 #[test]
1141 fn allow_local_network_opens_the_local_path() {
1142 let io = RecordingIo::arc();
1143 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1144 .with_local_network(true);
1145 assert_eq!(
1146 host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1147 .unwrap(),
1148 "g"
1149 );
1150 assert!(
1152 host.http_get("file:///etc/passwd", BTreeMap::new())
1153 .is_err()
1154 );
1155 }
1156
1157 static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1162
1163 fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1165 REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1166 }
1167
1168 #[test]
1171 fn redirect_switch_is_independent_of_the_host_field() {
1172 let _guard = lock_redirect_mirror();
1173 let io = RecordingIo::arc();
1174 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1175 let previous = local_network_allowed();
1176 set_local_network_allowed(true);
1177 let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1178 set_local_network_allowed(previous);
1179 assert!(
1180 decided.is_err(),
1181 "the host field, not the redirect mirror, decides the initial URL"
1182 );
1183 }
1184
1185 #[test]
1186 fn denied_calls_return_denied_and_skip_io() {
1187 let io = RecordingIo::arc();
1188 let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1189 assert!(
1190 host.http_get("http://x", BTreeMap::new())
1191 .unwrap_err()
1192 .contains("[denied]")
1193 );
1194 assert!(
1195 host.http_post("http://x", "b", BTreeMap::new())
1196 .unwrap_err()
1197 .contains("http_post")
1198 );
1199 assert!(host.shell("ls").unwrap_err().contains("shell"));
1200 assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1201 assert!(
1202 host.write_file("a.txt", "b")
1203 .unwrap_err()
1204 .contains("write_file")
1205 );
1206 assert!(host.env_var("X").unwrap_err().contains("env_var"));
1207 assert!(
1208 io.calls.lock().unwrap().is_empty(),
1209 "no I/O on denied calls"
1210 );
1211 }
1212
1213 #[test]
1214 fn read_file_confined_to_workdir() {
1215 let dir = tempfile::tempdir().unwrap();
1216 std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1217 let io = RecordingIo::arc();
1218 let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1219 assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1221 assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1222 let err = host.read_file("../../etc/passwd").unwrap_err();
1225 assert!(err.contains("escape"));
1226 let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1227 assert!(werr.contains("escape"));
1228 let calls = io.calls.lock().unwrap().clone();
1230 assert_eq!(calls.len(), 2);
1231 assert!(calls.iter().any(|c| c.starts_with("read:")));
1232 assert!(calls.iter().any(|c| c.starts_with("write:")));
1233 }
1234
1235 #[test]
1236 fn read_file_absolute_outside_workdir_rejected() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let host =
1239 DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1240 let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1245 assert!(outside.is_absolute(), "test path must be absolute");
1246 let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1247 assert!(err.contains("would escape"), "got: {err}");
1248 }
1249
1250 #[test]
1251 fn read_file_pop_past_root_rejected() {
1252 let host =
1256 DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1257 let err = host.read_file("../..").unwrap_err();
1258 assert!(err.contains("escapes the working directory"), "got: {err}");
1259 }
1260
1261 async fn mock_http() -> String {
1264 use axum::Router;
1265 use axum::routing::{get, post};
1266 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1267 let base = format!("http://{}", listener.local_addr().unwrap());
1268 let app = Router::new()
1269 .route("/ok", get(|| async { "GET-BODY" }))
1270 .route("/echo", post(|body: String| async move { body }))
1271 .route(
1272 "/boom",
1273 get(|| async {
1274 (
1275 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1276 "server error",
1277 )
1278 }),
1279 )
1280 .route(
1283 "/png",
1284 get(|| async {
1285 (
1286 [(axum::http::header::CONTENT_TYPE, "image/png")],
1287 vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1289 )
1290 }),
1291 )
1292 .route(
1295 "/shiftjis",
1296 get(|| async {
1297 (
1298 [(
1299 axum::http::header::CONTENT_TYPE,
1300 "text/html; charset=shift_jis",
1301 )],
1302 vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1304 )
1305 }),
1306 );
1307 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1308 listener, app,
1309 )));
1310 base
1311 }
1312
1313 #[test]
1314 fn binary_content_types_are_classified_but_structured_text_is_not() {
1315 for text in [
1316 "",
1317 "text/html; charset=utf-8",
1318 "text/plain",
1319 "application/json",
1320 "application/xml",
1321 "application/xhtml+xml",
1322 "application/ld+json",
1323 "application/javascript",
1324 ] {
1325 assert!(!is_binary_content_type(text), "should be text: {text:?}");
1326 }
1327 for binary in [
1328 "image/png",
1329 "IMAGE/PNG",
1330 "image/jpeg; charset=binary",
1331 " audio/mpeg ",
1332 "video/mp4",
1333 "font/woff2",
1334 "application/octet-stream",
1335 "application/pdf",
1336 "application/zip",
1337 "application/gzip",
1338 "application/x-tar",
1339 "application/x-bzip2",
1340 "application/wasm",
1341 "application/vnd.ms-excel",
1342 "application/msword",
1343 ] {
1344 assert!(
1345 is_binary_content_type(binary),
1346 "should be binary: {binary:?}"
1347 );
1348 }
1349 }
1350
1351 #[test]
1352 fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1353 let with_len = non_text_body_message("image/png", Some(2049));
1354 assert!(with_len.contains("image/png"), "got: {with_len}");
1355 assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1356 let without_len = non_text_body_message("audio/mpeg", None);
1357 assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1358 assert!(
1359 !without_len.contains("KB"),
1360 "no size to report: {without_len}"
1361 );
1362 }
1363
1364 #[tokio::test(flavor = "multi_thread")]
1365 async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1366 let base = mock_http().await;
1367 let (png, sjis) = tokio::task::spawn_blocking(move || {
1368 (
1369 RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1370 RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1371 )
1372 })
1373 .await
1374 .unwrap();
1375
1376 let err = png.unwrap_err();
1378 assert!(err.contains("non-text content"), "got: {err}");
1379 assert!(err.contains("image/png"), "got: {err}");
1380
1381 assert_eq!(sjis.unwrap(), "日本語");
1384 }
1385
1386 #[test]
1390 fn oversized_declared_body_is_refused() {
1391 let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1392 assert!(msg.contains("999999999"), "{msg}");
1393 assert!(msg.contains("1000-byte limit"), "{msg}");
1394 }
1395
1396 #[test]
1400 fn body_within_cap_or_of_unknown_size_proceeds() {
1401 assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1402 assert!(oversized_body_message(Some(0), 1_000).is_none());
1403 assert!(oversized_body_message(None, 1_000).is_none());
1404 }
1405
1406 #[tokio::test(flavor = "multi_thread")]
1410 async fn send_refuses_a_body_over_the_cap() {
1411 let base = mock_http().await;
1412 let out = tokio::task::spawn_blocking(move || {
1413 let client = RealScriptIo::client();
1414 RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1416 })
1417 .await
1418 .unwrap();
1419 let err = out.expect_err("a body over the cap is refused");
1420 assert!(err.contains("over the"), "got: {err}");
1421 }
1422
1423 #[tokio::test(flavor = "multi_thread")]
1428 async fn redirects_to_a_local_address_are_refused() {
1429 use axum::Router;
1430 use axum::response::Redirect;
1431 use axum::routing::get;
1432
1433 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1434 let addr = listener.local_addr().unwrap();
1435 let app = Router::new().route(
1439 "/bounce",
1440 get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1441 );
1442 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1443 listener, app,
1444 )));
1445
1446 let out = tokio::task::spawn_blocking(move || {
1450 let _guard = lock_redirect_mirror();
1451 let previous = local_network_allowed();
1452 set_local_network_allowed(false);
1453 let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1454 set_local_network_allowed(previous);
1455 result
1456 })
1457 .await
1458 .unwrap();
1459 let err = out.expect_err("a redirect to loopback must not be followed");
1460 assert!(err.contains("refused to follow redirect"), "got: {err}");
1461 }
1462
1463 #[tokio::test(flavor = "multi_thread")]
1466 async fn a_redirect_loop_is_bounded() {
1467 use axum::Router;
1468 use axum::response::Redirect;
1469 use axum::routing::get;
1470
1471 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1472 let addr = listener.local_addr().unwrap();
1473 let app = Router::new().route(
1474 "/loop",
1475 get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1476 );
1477 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1478 listener, app,
1479 )));
1480
1481 let out = tokio::task::spawn_blocking(move || {
1482 let _guard = lock_redirect_mirror();
1483 let previous = local_network_allowed();
1485 set_local_network_allowed(true);
1486 let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1487 set_local_network_allowed(previous);
1488 result
1489 })
1490 .await
1491 .unwrap();
1492 let err = out.expect_err("an endless redirect must be stopped");
1493 assert!(err.contains("too many redirects"), "got: {err}");
1494 }
1495
1496 #[test]
1501 fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1502 fn escapes(_: &Path, _: &Path) -> bool {
1503 false
1504 }
1505 let dir = tempfile::tempdir().unwrap();
1506 let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1507 .expect_err("a path that resolves outside must be refused");
1508 assert!(err.contains("symlink"), "{err}");
1509 }
1510
1511 #[test]
1514 fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let resolved =
1517 DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1518 .expect("an ordinary path resolves");
1519 assert!(resolved.ends_with("notes.txt"));
1520 }
1521
1522 #[cfg(unix)]
1525 #[test]
1526 fn script_host_read_refuses_a_symlink_escape() {
1527 let dir = tempfile::tempdir().unwrap();
1528 let workdir = dir.path().join("workspace");
1529 std::fs::create_dir(&workdir).unwrap();
1530 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1531
1532 let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1533 let err = host.read_file("link/etc/hosts").unwrap_err();
1534 assert!(err.contains("symlink"), "got: {err}");
1535 }
1536
1537 #[tokio::test(flavor = "multi_thread")]
1538 async fn real_http_get_success_and_headers() {
1539 let base = mock_http().await;
1540 let out = tokio::task::spawn_blocking(move || {
1541 let mut h = BTreeMap::new();
1542 h.insert("X-Test".to_string(), "1".to_string());
1543 RealScriptIo.http_get(&format!("{base}/ok"), h)
1544 })
1545 .await
1546 .unwrap();
1547 assert_eq!(out.unwrap(), "GET-BODY");
1548 }
1549
1550 #[tokio::test(flavor = "multi_thread")]
1551 async fn real_http_get_non_success_is_error() {
1552 let base = mock_http().await;
1553 let out = tokio::task::spawn_blocking(move || {
1554 RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1555 })
1556 .await
1557 .unwrap();
1558 let err = out.unwrap_err();
1559 assert!(
1560 err.contains("http 500") && err.contains("server error"),
1561 "got: {err}"
1562 );
1563 }
1564
1565 #[tokio::test(flavor = "multi_thread")]
1566 async fn real_http_get_connection_error() {
1567 let out = tokio::task::spawn_blocking(|| {
1569 RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1570 })
1571 .await
1572 .unwrap();
1573 assert!(out.unwrap_err().contains("request failed"));
1574 }
1575
1576 async fn spawn_truncated_body_server() -> String {
1580 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1581 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1582 let addr = listener.local_addr().unwrap();
1583 let body = b"partial";
1584 let response = format!(
1585 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1586 body.len() + 4096
1587 )
1588 .into_bytes();
1589 tokio::spawn(async move {
1590 let (mut socket, _) = listener.accept().await.unwrap();
1591 let mut buf = [0u8; 8192];
1592 let _ = socket.read(&mut buf).await;
1593 let _ = socket.write_all(&response).await;
1594 let _ = socket.write_all(body).await;
1595 let _ = socket.flush().await;
1596 let _ = socket.shutdown().await;
1597 });
1598 format!("http://{addr}")
1599 }
1600
1601 #[tokio::test(flavor = "multi_thread")]
1602 async fn real_http_body_read_error() {
1603 let base = spawn_truncated_body_server().await;
1604 let out = tokio::task::spawn_blocking(move || {
1605 RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1606 })
1607 .await
1608 .unwrap();
1609 let err = out.unwrap_err();
1610 assert!(err.contains("read body"), "got: {err}");
1611 }
1612
1613 #[tokio::test(flavor = "multi_thread")]
1614 async fn real_http_post_echoes_body() {
1615 let base = mock_http().await;
1616 let out = tokio::task::spawn_blocking(move || {
1617 RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1618 })
1619 .await
1620 .unwrap();
1621 assert_eq!(out.unwrap(), "hello");
1622 }
1623
1624 async fn run_host_shell(
1627 command: &'static str,
1628 workdir: PathBuf,
1629 timeout: Duration,
1630 ) -> Result<String, String> {
1631 tokio::task::spawn_blocking(move || {
1632 let (shell, flag) = default_shell();
1633 let cmd = host_shell_command(shell, flag, command, &workdir);
1634 RealScriptIo.run_shell(cmd, timeout)
1635 })
1636 .await
1637 .unwrap()
1638 }
1639
1640 #[test]
1641 fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1642 let dir = tempfile::tempdir().unwrap();
1647 let workdir = dir.path().to_path_buf();
1648 let err = std::thread::spawn(move || {
1649 let (shell, flag) = default_shell();
1650 let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1651 RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1652 })
1653 .join()
1654 .unwrap()
1655 .unwrap_err();
1656 assert!(err.contains("no tokio runtime"), "got: {err}");
1657 }
1658
1659 #[tokio::test(flavor = "multi_thread")]
1660 async fn real_shell_runs_and_captures_output() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let out = run_host_shell(
1664 "echo hello",
1665 dir.path().to_path_buf(),
1666 Duration::from_secs(30),
1667 )
1668 .await
1669 .unwrap();
1670 assert!(out.contains("hello"));
1671 let out2 = run_host_shell(
1673 "echo oops 1>&2",
1674 dir.path().to_path_buf(),
1675 Duration::from_secs(30),
1676 )
1677 .await
1678 .unwrap();
1679 assert!(out2.contains("oops"));
1680 }
1681
1682 #[tokio::test(flavor = "multi_thread")]
1683 async fn real_shell_spawn_failure() {
1684 let missing = PathBuf::from("/no/such/workdir/leviath");
1686 let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1687 .await
1688 .unwrap_err();
1689 assert!(err.contains("failed to spawn shell"), "got: {err}");
1690 }
1691
1692 #[tokio::test(flavor = "multi_thread")]
1693 async fn real_shell_times_out() {
1694 let dir = tempfile::tempdir().unwrap();
1696 let err = run_host_shell(
1697 "sleep 5",
1698 dir.path().to_path_buf(),
1699 Duration::from_millis(50),
1700 )
1701 .await
1702 .unwrap_err();
1703 assert!(err.contains("timed out"), "got: {err}");
1704 }
1705
1706 #[test]
1707 fn combine_shell_output_appends_nonempty_stderr_only() {
1708 assert_eq!(combine_shell_output(b"out", b" "), "out");
1710 assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1711 }
1712
1713 #[test]
1714 fn host_shell_command_targets_workdir() {
1715 let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1716 assert_eq!(cmd.as_std().get_program(), "sh");
1717 }
1718
1719 #[test]
1720 fn shell_routes_through_sandbox_when_present() {
1721 use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1722 let by_index = vec![ToolSandboxConfig {
1726 kind: SandboxKind::Namespace,
1727 on_unavailable: OnUnavailable::Warn,
1728 ..Default::default()
1729 }];
1730 let sb = SandboxManager::build("r", by_index, "/w", 0)
1731 .unwrap()
1732 .map(Arc::new);
1733 assert!(sb.is_some(), "namespace warn config yields a manager");
1734 let io = RecordingIo::arc();
1735 let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1736 .with_shell(sb, Duration::from_secs(5));
1737 assert_eq!(host.shell("ls").unwrap(), "s");
1738 assert!(
1739 io.calls
1740 .lock()
1741 .unwrap()
1742 .iter()
1743 .any(|c| c.starts_with("shell:"))
1744 );
1745 }
1746
1747 #[test]
1748 fn real_read_file_success_and_error() {
1749 let dir = tempfile::tempdir().unwrap();
1750 let p = dir.path().join("f.txt");
1751 std::fs::write(&p, "data").unwrap();
1752 assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1753 let err = RealScriptIo
1754 .read_file(&dir.path().join("nope"))
1755 .unwrap_err();
1756 assert!(err.contains("read '"));
1757 }
1758
1759 #[test]
1760 fn real_write_file_creates_parents_and_reports() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let nested = dir.path().join("sub/deep/out.txt");
1764 let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1765 assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1766 assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1767 }
1768
1769 #[test]
1770 fn real_write_file_create_dir_error() {
1771 let dir = tempfile::tempdir().unwrap();
1772 let blocker = dir.path().join("afile");
1774 std::fs::write(&blocker, "x").unwrap();
1775 let err = RealScriptIo
1776 .write_file(&blocker.join("child.txt"), "b")
1777 .unwrap_err();
1778 assert!(err.contains("create dir"), "got: {err}");
1779 }
1780
1781 #[test]
1782 fn real_write_file_write_error() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1786 assert!(err.contains("write '"), "got: {err}");
1787 }
1788
1789 #[test]
1790 fn real_write_file_parentless_path() {
1791 let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1794 assert!(err.contains("write '"), "got: {err}");
1795 }
1796
1797 #[test]
1798 fn real_env_var_set_and_unset() {
1799 temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1800 assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1801 });
1802 temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1803 assert!(
1804 RealScriptIo
1805 .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1806 .unwrap_err()
1807 .contains("not set")
1808 );
1809 });
1810 }
1811
1812 #[test]
1813 fn default_shell_is_platform_appropriate() {
1814 let (shell, flag) = default_shell();
1815 assert!(!shell.is_empty());
1816 assert!(!flag.is_empty());
1817 }
1818
1819 #[test]
1820 fn new_wires_real_io() {
1821 let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1823 temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1825 assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1826 });
1827 }
1828
1829 #[test]
1830 fn cap_script_io_leaves_small_strings_untouched() {
1831 let s = "small".to_string();
1832 assert_eq!(cap_script_io(s.clone()), s);
1833 }
1834
1835 #[test]
1836 fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1837 let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1838 let capped = cap_script_io(big);
1839 assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1840 assert!(capped.contains("[...truncated by leviath"));
1841 }
1842
1843 #[test]
1844 fn cap_script_io_truncates_on_a_char_boundary() {
1845 let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1847 s.push('é'); s.push_str(&"b".repeat(10));
1849 let capped = cap_script_io(s);
1850 assert!(capped.contains("[...truncated by leviath"));
1852 }
1853}