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 leviath_tools::hide_console_window(&mut cmd);
699 cmd.stdout(std::process::Stdio::piped())
702 .stderr(std::process::Stdio::piped());
703 handle.block_on(async move {
704 let run = async {
709 let child = cmd.spawn()?;
710 let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
711 child.wait_with_output().await
712 };
713 match tokio::time::timeout(timeout, run).await {
714 Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
715 &output.stdout,
716 &output.stderr,
717 ))),
718 Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
719 Err(_) => Err(format!(
720 "shell command timed out after {}s",
721 timeout.as_secs()
722 )),
723 }
724 })
725 }
726
727 fn read_file(&self, path: &Path) -> Result<String, String> {
728 std::fs::read_to_string(path)
729 .map(cap_script_io)
730 .map_err(|e| format!("read '{}': {e}", path.display()))
731 }
732
733 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
734 if let Some(parent) = path.parent() {
735 std::fs::create_dir_all(parent)
736 .map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
737 }
738 std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
739 Ok(format!(
740 "wrote {} bytes to {}",
741 content.len(),
742 path.display()
743 ))
744 }
745
746 fn env_var(&self, name: &str) -> Result<String, String> {
747 std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
748 }
749}
750
751pub(crate) fn default_shell() -> (&'static str, &'static str) {
758 default_shell_for(std::env::consts::OS)
759}
760
761pub(crate) fn default_shell_for(os: &str) -> (&'static str, &'static str) {
767 match os {
768 "windows" => ("cmd.exe", "/C"),
769 _ => ("/bin/sh", "-c"),
770 }
771}
772
773pub(crate) fn host_shell_command(
776 shell: &str,
777 flag: &str,
778 command: &str,
779 workdir: &Path,
780) -> TokioCommand {
781 let mut c = TokioCommand::new(shell);
782 c.arg(flag).arg(command).current_dir(workdir);
783 c
784}
785
786pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
789 let mut out = String::from_utf8_lossy(stdout).into_owned();
790 let err = String::from_utf8_lossy(stderr);
791 if !err.trim().is_empty() {
792 out.push_str(&err);
793 }
794 out
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use std::sync::Mutex;
801
802 fn perms(all: ScriptPermission) -> ScriptToolPermissions {
805 ScriptToolPermissions {
806 http_get: all,
807 http_post: all,
808 shell: all,
809 read_file: all,
810 write_file: all,
811 env_var: all,
812 }
813 }
814
815 #[test]
816 fn resolve_allow_permits_everything() {
817 let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
818 assert_eq!(
819 a,
820 ScriptAllow {
821 http_get: true,
822 http_post: true,
823 shell: true,
824 read_file: true,
825 write_file: true,
826 env_var: true,
827 }
828 );
829 }
830
831 #[test]
832 fn resolve_deny_blocks_everything() {
833 let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
834 assert_eq!(
835 a,
836 ScriptAllow {
837 http_get: false,
838 http_post: false,
839 shell: false,
840 read_file: false,
841 write_file: false,
842 env_var: false,
843 }
844 );
845 }
846
847 #[test]
848 fn resolve_inherit_net_true_filelike_follows_builtin() {
849 let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
851 "read_file" => ToolPolicy::Allow,
852 _ => ToolPolicy::Ask,
853 });
854 assert!(a.http_get && a.http_post && a.env_var);
855 assert!(a.read_file, "read_file inherit → Allow");
856 assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
857 assert!(!a.shell, "shell inherit → Ask ⇒ denied");
858 }
859
860 #[test]
863 fn effective_perms_agent_tightens_per_field() {
864 let global = perms(ScriptPermission::Allow);
868 let manifest = "\
869 [tool_script_permissions]\n\
870 http_get = \"allow\"\n\
871 shell = \"deny\"\n\
872 write_file = \"inherit\"\n";
873 let eff = effective_script_permissions(&global, manifest);
874 assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
875 assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
876 assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
877 assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
878 assert_eq!(eff.read_file, ScriptPermission::Allow);
879 assert_eq!(eff.http_post, ScriptPermission::Allow);
880 }
881
882 #[test]
887 fn effective_perms_agent_cannot_loosen_global() {
888 let global = perms(ScriptPermission::Deny);
889 let manifest = "\
890 [tool_script_permissions]\n\
891 http_get = \"allow\"\n\
892 shell = \"allow\"\n\
893 env_var = \"inherit\"\n";
894 let eff = effective_script_permissions(&global, manifest);
895 assert_eq!(eff.http_get, ScriptPermission::Deny);
896 assert_eq!(eff.shell, ScriptPermission::Deny);
897 assert_eq!(eff.env_var, ScriptPermission::Deny);
898 }
899
900 #[test]
903 fn effective_perms_agent_cannot_promote_inherit_to_allow() {
904 let global = perms(ScriptPermission::Inherit);
905 let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
906 let eff = effective_script_permissions(&global, manifest);
907 assert_eq!(eff.shell, ScriptPermission::Inherit);
908 }
909
910 #[test]
911 fn effective_perms_absent_section_keeps_global() {
912 let global = perms(ScriptPermission::Deny);
913 let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
915 assert_eq!(eff.shell, ScriptPermission::Deny);
916 assert_eq!(eff.http_get, ScriptPermission::Deny);
917 }
918
919 #[test]
920 fn effective_perms_malformed_inputs_fall_back_to_global() {
921 let global = perms(ScriptPermission::Allow);
922 let eff = effective_script_permissions(&global, "not = valid = toml");
924 assert_eq!(eff.shell, ScriptPermission::Allow);
925 let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
927 assert_eq!(eff2.shell, ScriptPermission::Allow);
928 let eff3 =
930 effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
931 assert_eq!(eff3.shell, ScriptPermission::Allow);
932 }
933
934 struct RecordingIo {
937 calls: Mutex<Vec<String>>,
938 }
939 impl RecordingIo {
940 fn arc() -> Arc<RecordingIo> {
941 Arc::new(RecordingIo {
942 calls: Mutex::new(Vec::new()),
943 })
944 }
945 }
946 impl ScriptIo for RecordingIo {
947 fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
948 self.calls.lock().unwrap().push(format!("get:{url}"));
949 Ok("g".into())
950 }
951 fn http_post(
952 &self,
953 url: &str,
954 body: &str,
955 _h: BTreeMap<String, String>,
956 ) -> Result<String, String> {
957 self.calls
958 .lock()
959 .unwrap()
960 .push(format!("post:{url}:{body}"));
961 Ok("p".into())
962 }
963 fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
964 let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
966 self.calls.lock().unwrap().push(format!("shell:{prog}"));
967 Ok("s".into())
968 }
969 fn read_file(&self, path: &Path) -> Result<String, String> {
970 self.calls
971 .lock()
972 .unwrap()
973 .push(format!("read:{}", path.display()));
974 Ok("r".into())
975 }
976 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
977 self.calls
978 .lock()
979 .unwrap()
980 .push(format!("write:{}:{content}", path.display()));
981 Ok("w".into())
982 }
983 fn env_var(&self, name: &str) -> Result<String, String> {
984 self.calls.lock().unwrap().push(format!("env:{name}"));
985 Ok("e".into())
986 }
987 }
988
989 fn all_allowed() -> ScriptAllow {
990 ScriptAllow {
991 http_get: true,
992 http_post: true,
993 shell: true,
994 read_file: true,
995 write_file: true,
996 env_var: true,
997 }
998 }
999
1000 fn none_allowed() -> ScriptAllow {
1001 ScriptAllow {
1002 http_get: false,
1003 http_post: false,
1004 shell: false,
1005 read_file: false,
1006 write_file: false,
1007 env_var: false,
1008 }
1009 }
1010
1011 #[test]
1012 fn script_write_refuses_a_deleted_workspace() {
1013 let dir = tempfile::tempdir().unwrap();
1016 let workdir = dir.path().join("gone");
1017 let io = RecordingIo::arc();
1018 let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1019 let err = host.write_file("out.txt", "body").unwrap_err();
1020 assert!(err.contains("no longer accessible"), "got: {err}");
1021 assert!(
1022 io.calls.lock().unwrap().is_empty(),
1023 "the io layer never ran"
1024 );
1025 std::fs::create_dir(&workdir).unwrap();
1027 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1028 }
1029
1030 const PUBLIC_URL: &str = "http://93.184.216.34/";
1034
1035 #[test]
1036 fn allowed_calls_delegate_to_io() {
1037 let io = RecordingIo::arc();
1038 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1039 assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1040 assert_eq!(
1041 host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1042 "p"
1043 );
1044 assert_eq!(host.shell("ls").unwrap(), "s");
1045 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1046 assert_eq!(host.env_var("HOME").unwrap(), "e");
1047 let calls = io.calls.lock().unwrap().clone();
1048 assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1049 assert!(calls.iter().any(|c| c.starts_with("post:")));
1050 assert!(calls.iter().any(|c| c.starts_with("shell:")));
1052 assert!(
1053 calls
1054 .iter()
1055 .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1056 );
1057 assert!(calls.contains(&"env:HOME".to_string()));
1058 }
1059
1060 #[test]
1064 fn outbound_check_blocks_local_targets_before_any_io() {
1065 let io = RecordingIo::arc();
1066 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1067 for url in [
1068 "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1070 "http://127.0.0.1:3000/api/agents",
1072 "http://192.168.1.1/",
1074 "file:///etc/passwd",
1076 ] {
1077 let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1078 assert!(err.starts_with("[denied]"), "{url} → {err}");
1079 let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1080 assert!(err.starts_with("[denied]"), "{url} → {err}");
1081 }
1082 let calls = io.calls.lock().unwrap().clone();
1083 assert!(
1084 calls.is_empty(),
1085 "a refused URL must never reach the I/O backend: {calls:?}"
1086 );
1087 }
1088
1089 #[test]
1094 fn env_var_refuses_credential_names_by_default() {
1095 let io = RecordingIo::arc();
1096 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1097 for name in [
1098 "ANTHROPIC_API_KEY",
1099 "OPENAI_API_KEY",
1100 "AWS_SECRET_ACCESS_KEY",
1101 "GITHUB_TOKEN",
1102 "LEVIATH_API_TOKEN",
1103 ] {
1104 let err = host.env_var(name).unwrap_err();
1105 assert!(err.starts_with("[denied]"), "{name} → {err}");
1106 assert!(err.contains("allow_env_vars"), "{name} → {err}");
1107 }
1108 assert!(
1109 io.calls.lock().unwrap().is_empty(),
1110 "a refused read must never reach the I/O backend"
1111 );
1112 }
1113
1114 #[test]
1117 fn env_var_allows_ordinary_names() {
1118 let io = RecordingIo::arc();
1119 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1120 assert_eq!(host.env_var("PATH").unwrap(), "e");
1121 assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1122 }
1123
1124 #[test]
1127 fn env_var_allowlist_permits_exactly_the_named_variable() {
1128 let io = RecordingIo::arc();
1129 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1130 .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1131 assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1132 assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1133 }
1134
1135 #[test]
1138 fn outbound_check_rejects_unparseable_urls() {
1139 let io = RecordingIo::arc();
1140 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1141 let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1142 assert!(err.contains("invalid URL"), "{err}");
1143 assert!(io.calls.lock().unwrap().is_empty());
1144 }
1145
1146 #[test]
1150 fn allow_local_network_opens_the_local_path() {
1151 let io = RecordingIo::arc();
1152 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1153 .with_local_network(true);
1154 assert_eq!(
1155 host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1156 .unwrap(),
1157 "g"
1158 );
1159 assert!(
1161 host.http_get("file:///etc/passwd", BTreeMap::new())
1162 .is_err()
1163 );
1164 }
1165
1166 static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1171
1172 fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1174 REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1175 }
1176
1177 #[test]
1180 fn redirect_switch_is_independent_of_the_host_field() {
1181 let _guard = lock_redirect_mirror();
1182 let io = RecordingIo::arc();
1183 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1184 let previous = local_network_allowed();
1185 set_local_network_allowed(true);
1186 let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1187 set_local_network_allowed(previous);
1188 assert!(
1189 decided.is_err(),
1190 "the host field, not the redirect mirror, decides the initial URL"
1191 );
1192 }
1193
1194 #[test]
1195 fn denied_calls_return_denied_and_skip_io() {
1196 let io = RecordingIo::arc();
1197 let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1198 assert!(
1199 host.http_get("http://x", BTreeMap::new())
1200 .unwrap_err()
1201 .contains("[denied]")
1202 );
1203 assert!(
1204 host.http_post("http://x", "b", BTreeMap::new())
1205 .unwrap_err()
1206 .contains("http_post")
1207 );
1208 assert!(host.shell("ls").unwrap_err().contains("shell"));
1209 assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1210 assert!(
1211 host.write_file("a.txt", "b")
1212 .unwrap_err()
1213 .contains("write_file")
1214 );
1215 assert!(host.env_var("X").unwrap_err().contains("env_var"));
1216 assert!(
1217 io.calls.lock().unwrap().is_empty(),
1218 "no I/O on denied calls"
1219 );
1220 }
1221
1222 #[test]
1223 fn read_file_confined_to_workdir() {
1224 let dir = tempfile::tempdir().unwrap();
1225 std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1226 let io = RecordingIo::arc();
1227 let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1228 assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1230 assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1231 let err = host.read_file("../../etc/passwd").unwrap_err();
1234 assert!(err.contains("escape"));
1235 let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1236 assert!(werr.contains("escape"));
1237 let calls = io.calls.lock().unwrap().clone();
1239 assert_eq!(calls.len(), 2);
1240 assert!(calls.iter().any(|c| c.starts_with("read:")));
1241 assert!(calls.iter().any(|c| c.starts_with("write:")));
1242 }
1243
1244 #[test]
1245 fn read_file_absolute_outside_workdir_rejected() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let host =
1248 DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1249 let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1254 assert!(outside.is_absolute(), "test path must be absolute");
1255 let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1256 assert!(err.contains("would escape"), "got: {err}");
1257 }
1258
1259 #[test]
1260 fn read_file_pop_past_root_rejected() {
1261 let host =
1265 DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1266 let err = host.read_file("../..").unwrap_err();
1267 assert!(err.contains("escapes the working directory"), "got: {err}");
1268 }
1269
1270 async fn mock_http() -> String {
1273 use axum::Router;
1274 use axum::routing::{get, post};
1275 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1276 let base = format!("http://{}", listener.local_addr().unwrap());
1277 let app = Router::new()
1278 .route("/ok", get(|| async { "GET-BODY" }))
1279 .route("/echo", post(|body: String| async move { body }))
1280 .route(
1281 "/boom",
1282 get(|| async {
1283 (
1284 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1285 "server error",
1286 )
1287 }),
1288 )
1289 .route(
1292 "/png",
1293 get(|| async {
1294 (
1295 [(axum::http::header::CONTENT_TYPE, "image/png")],
1296 vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1298 )
1299 }),
1300 )
1301 .route(
1304 "/shiftjis",
1305 get(|| async {
1306 (
1307 [(
1308 axum::http::header::CONTENT_TYPE,
1309 "text/html; charset=shift_jis",
1310 )],
1311 vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1313 )
1314 }),
1315 );
1316 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1317 listener, app,
1318 )));
1319 base
1320 }
1321
1322 #[test]
1323 fn binary_content_types_are_classified_but_structured_text_is_not() {
1324 for text in [
1325 "",
1326 "text/html; charset=utf-8",
1327 "text/plain",
1328 "application/json",
1329 "application/xml",
1330 "application/xhtml+xml",
1331 "application/ld+json",
1332 "application/javascript",
1333 ] {
1334 assert!(!is_binary_content_type(text), "should be text: {text:?}");
1335 }
1336 for binary in [
1337 "image/png",
1338 "IMAGE/PNG",
1339 "image/jpeg; charset=binary",
1340 " audio/mpeg ",
1341 "video/mp4",
1342 "font/woff2",
1343 "application/octet-stream",
1344 "application/pdf",
1345 "application/zip",
1346 "application/gzip",
1347 "application/x-tar",
1348 "application/x-bzip2",
1349 "application/wasm",
1350 "application/vnd.ms-excel",
1351 "application/msword",
1352 ] {
1353 assert!(
1354 is_binary_content_type(binary),
1355 "should be binary: {binary:?}"
1356 );
1357 }
1358 }
1359
1360 #[test]
1361 fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1362 let with_len = non_text_body_message("image/png", Some(2049));
1363 assert!(with_len.contains("image/png"), "got: {with_len}");
1364 assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1365 let without_len = non_text_body_message("audio/mpeg", None);
1366 assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1367 assert!(
1368 !without_len.contains("KB"),
1369 "no size to report: {without_len}"
1370 );
1371 }
1372
1373 #[tokio::test(flavor = "multi_thread")]
1374 async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1375 let base = mock_http().await;
1376 let (png, sjis) = tokio::task::spawn_blocking(move || {
1377 (
1378 RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1379 RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1380 )
1381 })
1382 .await
1383 .unwrap();
1384
1385 let err = png.unwrap_err();
1387 assert!(err.contains("non-text content"), "got: {err}");
1388 assert!(err.contains("image/png"), "got: {err}");
1389
1390 assert_eq!(sjis.unwrap(), "日本語");
1393 }
1394
1395 #[test]
1399 fn oversized_declared_body_is_refused() {
1400 let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1401 assert!(msg.contains("999999999"), "{msg}");
1402 assert!(msg.contains("1000-byte limit"), "{msg}");
1403 }
1404
1405 #[test]
1409 fn body_within_cap_or_of_unknown_size_proceeds() {
1410 assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1411 assert!(oversized_body_message(Some(0), 1_000).is_none());
1412 assert!(oversized_body_message(None, 1_000).is_none());
1413 }
1414
1415 #[tokio::test(flavor = "multi_thread")]
1419 async fn send_refuses_a_body_over_the_cap() {
1420 let base = mock_http().await;
1421 let out = tokio::task::spawn_blocking(move || {
1422 let client = RealScriptIo::client();
1423 RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1425 })
1426 .await
1427 .unwrap();
1428 let err = out.expect_err("a body over the cap is refused");
1429 assert!(err.contains("over the"), "got: {err}");
1430 }
1431
1432 #[tokio::test(flavor = "multi_thread")]
1437 async fn redirects_to_a_local_address_are_refused() {
1438 use axum::Router;
1439 use axum::response::Redirect;
1440 use axum::routing::get;
1441
1442 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1443 let addr = listener.local_addr().unwrap();
1444 let app = Router::new().route(
1448 "/bounce",
1449 get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1450 );
1451 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1452 listener, app,
1453 )));
1454
1455 let out = tokio::task::spawn_blocking(move || {
1459 let _guard = lock_redirect_mirror();
1460 let previous = local_network_allowed();
1461 set_local_network_allowed(false);
1462 let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1463 set_local_network_allowed(previous);
1464 result
1465 })
1466 .await
1467 .unwrap();
1468 let err = out.expect_err("a redirect to loopback must not be followed");
1469 assert!(err.contains("refused to follow redirect"), "got: {err}");
1470 }
1471
1472 #[tokio::test(flavor = "multi_thread")]
1475 async fn a_redirect_loop_is_bounded() {
1476 use axum::Router;
1477 use axum::response::Redirect;
1478 use axum::routing::get;
1479
1480 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1481 let addr = listener.local_addr().unwrap();
1482 let app = Router::new().route(
1483 "/loop",
1484 get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1485 );
1486 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1487 listener, app,
1488 )));
1489
1490 let out = tokio::task::spawn_blocking(move || {
1491 let _guard = lock_redirect_mirror();
1492 let previous = local_network_allowed();
1494 set_local_network_allowed(true);
1495 let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1496 set_local_network_allowed(previous);
1497 result
1498 })
1499 .await
1500 .unwrap();
1501 let err = out.expect_err("an endless redirect must be stopped");
1502 assert!(err.contains("too many redirects"), "got: {err}");
1503 }
1504
1505 #[test]
1510 fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1511 fn escapes(_: &Path, _: &Path) -> bool {
1512 false
1513 }
1514 let dir = tempfile::tempdir().unwrap();
1515 let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1516 .expect_err("a path that resolves outside must be refused");
1517 assert!(err.contains("symlink"), "{err}");
1518 }
1519
1520 #[test]
1523 fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let resolved =
1526 DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1527 .expect("an ordinary path resolves");
1528 assert!(resolved.ends_with("notes.txt"));
1529 }
1530
1531 #[cfg(unix)]
1534 #[test]
1535 fn script_host_read_refuses_a_symlink_escape() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let workdir = dir.path().join("workspace");
1538 std::fs::create_dir(&workdir).unwrap();
1539 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1540
1541 let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1542 let err = host.read_file("link/etc/hosts").unwrap_err();
1543 assert!(err.contains("symlink"), "got: {err}");
1544 }
1545
1546 #[tokio::test(flavor = "multi_thread")]
1547 async fn real_http_get_success_and_headers() {
1548 let base = mock_http().await;
1549 let out = tokio::task::spawn_blocking(move || {
1550 let mut h = BTreeMap::new();
1551 h.insert("X-Test".to_string(), "1".to_string());
1552 RealScriptIo.http_get(&format!("{base}/ok"), h)
1553 })
1554 .await
1555 .unwrap();
1556 assert_eq!(out.unwrap(), "GET-BODY");
1557 }
1558
1559 #[tokio::test(flavor = "multi_thread")]
1560 async fn real_http_get_non_success_is_error() {
1561 let base = mock_http().await;
1562 let out = tokio::task::spawn_blocking(move || {
1563 RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1564 })
1565 .await
1566 .unwrap();
1567 let err = out.unwrap_err();
1568 assert!(
1569 err.contains("http 500") && err.contains("server error"),
1570 "got: {err}"
1571 );
1572 }
1573
1574 #[tokio::test(flavor = "multi_thread")]
1575 async fn real_http_get_connection_error() {
1576 let out = tokio::task::spawn_blocking(|| {
1578 RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1579 })
1580 .await
1581 .unwrap();
1582 assert!(out.unwrap_err().contains("request failed"));
1583 }
1584
1585 async fn spawn_truncated_body_server() -> String {
1589 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1590 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1591 let addr = listener.local_addr().unwrap();
1592 let body = b"partial";
1593 let response = format!(
1594 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1595 body.len() + 4096
1596 )
1597 .into_bytes();
1598 tokio::spawn(async move {
1599 let (mut socket, _) = listener.accept().await.unwrap();
1600 let mut buf = [0u8; 8192];
1601 let _ = socket.read(&mut buf).await;
1602 let _ = socket.write_all(&response).await;
1603 let _ = socket.write_all(body).await;
1604 let _ = socket.flush().await;
1605 let _ = socket.shutdown().await;
1606 });
1607 format!("http://{addr}")
1608 }
1609
1610 #[tokio::test(flavor = "multi_thread")]
1611 async fn real_http_body_read_error() {
1612 let base = spawn_truncated_body_server().await;
1613 let out = tokio::task::spawn_blocking(move || {
1614 RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1615 })
1616 .await
1617 .unwrap();
1618 let err = out.unwrap_err();
1619 assert!(err.contains("read body"), "got: {err}");
1620 }
1621
1622 #[tokio::test(flavor = "multi_thread")]
1623 async fn real_http_post_echoes_body() {
1624 let base = mock_http().await;
1625 let out = tokio::task::spawn_blocking(move || {
1626 RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1627 })
1628 .await
1629 .unwrap();
1630 assert_eq!(out.unwrap(), "hello");
1631 }
1632
1633 async fn run_host_shell(
1636 command: &'static str,
1637 workdir: PathBuf,
1638 timeout: Duration,
1639 ) -> Result<String, String> {
1640 tokio::task::spawn_blocking(move || {
1641 let (shell, flag) = default_shell();
1642 let cmd = host_shell_command(shell, flag, command, &workdir);
1643 RealScriptIo.run_shell(cmd, timeout)
1644 })
1645 .await
1646 .unwrap()
1647 }
1648
1649 #[test]
1650 fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1651 let dir = tempfile::tempdir().unwrap();
1656 let workdir = dir.path().to_path_buf();
1657 let err = std::thread::spawn(move || {
1658 let (shell, flag) = default_shell();
1659 let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1660 RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1661 })
1662 .join()
1663 .unwrap()
1664 .unwrap_err();
1665 assert!(err.contains("no tokio runtime"), "got: {err}");
1666 }
1667
1668 #[tokio::test(flavor = "multi_thread")]
1669 async fn real_shell_runs_and_captures_output() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let out = run_host_shell(
1673 "echo hello",
1674 dir.path().to_path_buf(),
1675 Duration::from_secs(30),
1676 )
1677 .await
1678 .unwrap();
1679 assert!(out.contains("hello"));
1680 let out2 = run_host_shell(
1682 "echo oops 1>&2",
1683 dir.path().to_path_buf(),
1684 Duration::from_secs(30),
1685 )
1686 .await
1687 .unwrap();
1688 assert!(out2.contains("oops"));
1689 }
1690
1691 #[tokio::test(flavor = "multi_thread")]
1692 async fn real_shell_spawn_failure() {
1693 let missing = PathBuf::from("/no/such/workdir/leviath");
1695 let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1696 .await
1697 .unwrap_err();
1698 assert!(err.contains("failed to spawn shell"), "got: {err}");
1699 }
1700
1701 #[tokio::test(flavor = "multi_thread")]
1702 async fn real_shell_times_out() {
1703 let dir = tempfile::tempdir().unwrap();
1705 let err = run_host_shell(
1706 "sleep 5",
1707 dir.path().to_path_buf(),
1708 Duration::from_millis(50),
1709 )
1710 .await
1711 .unwrap_err();
1712 assert!(err.contains("timed out"), "got: {err}");
1713 }
1714
1715 #[test]
1716 fn combine_shell_output_appends_nonempty_stderr_only() {
1717 assert_eq!(combine_shell_output(b"out", b" "), "out");
1719 assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1720 }
1721
1722 #[test]
1723 fn host_shell_command_targets_workdir() {
1724 let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1725 assert_eq!(cmd.as_std().get_program(), "sh");
1726 }
1727
1728 #[test]
1729 fn shell_routes_through_sandbox_when_present() {
1730 use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1731 let by_index = vec![ToolSandboxConfig {
1735 kind: SandboxKind::Namespace,
1736 on_unavailable: OnUnavailable::Warn,
1737 ..Default::default()
1738 }];
1739 let sb = SandboxManager::build("r", by_index, "/w", 0)
1740 .unwrap()
1741 .map(Arc::new);
1742 assert!(sb.is_some(), "namespace warn config yields a manager");
1743 let io = RecordingIo::arc();
1744 let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1745 .with_shell(sb, Duration::from_secs(5));
1746 assert_eq!(host.shell("ls").unwrap(), "s");
1747 assert!(
1748 io.calls
1749 .lock()
1750 .unwrap()
1751 .iter()
1752 .any(|c| c.starts_with("shell:"))
1753 );
1754 }
1755
1756 #[test]
1757 fn real_read_file_success_and_error() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let p = dir.path().join("f.txt");
1760 std::fs::write(&p, "data").unwrap();
1761 assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1762 let err = RealScriptIo
1763 .read_file(&dir.path().join("nope"))
1764 .unwrap_err();
1765 assert!(err.contains("read '"));
1766 }
1767
1768 #[test]
1769 fn real_write_file_creates_parents_and_reports() {
1770 let dir = tempfile::tempdir().unwrap();
1771 let nested = dir.path().join("sub/deep/out.txt");
1773 let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1774 assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1775 assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1776 }
1777
1778 #[test]
1779 fn real_write_file_create_dir_error() {
1780 let dir = tempfile::tempdir().unwrap();
1781 let blocker = dir.path().join("afile");
1783 std::fs::write(&blocker, "x").unwrap();
1784 let err = RealScriptIo
1785 .write_file(&blocker.join("child.txt"), "b")
1786 .unwrap_err();
1787 assert!(err.contains("create dir"), "got: {err}");
1788 }
1789
1790 #[test]
1791 fn real_write_file_write_error() {
1792 let dir = tempfile::tempdir().unwrap();
1793 let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1795 assert!(err.contains("write '"), "got: {err}");
1796 }
1797
1798 #[test]
1799 fn real_write_file_parentless_path() {
1800 let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1803 assert!(err.contains("write '"), "got: {err}");
1804 }
1805
1806 #[test]
1807 fn real_env_var_set_and_unset() {
1808 temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1809 assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1810 });
1811 temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1812 assert!(
1813 RealScriptIo
1814 .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1815 .unwrap_err()
1816 .contains("not set")
1817 );
1818 });
1819 }
1820
1821 #[test]
1822 fn default_shell_is_platform_appropriate() {
1823 let (shell, flag) = default_shell();
1824 assert!(!shell.is_empty());
1825 assert!(!flag.is_empty());
1826 }
1827
1828 #[test]
1833 fn default_shell_for_answers_per_platform() {
1834 assert_eq!(default_shell_for("windows"), ("cmd.exe", "/C"));
1835 for posix in ["linux", "macos", "freebsd", "haiku"] {
1836 assert_eq!(default_shell_for(posix), ("/bin/sh", "-c"), "{posix}");
1837 }
1838 }
1839
1840 #[test]
1841 fn new_wires_real_io() {
1842 let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1844 temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1846 assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1847 });
1848 }
1849
1850 #[test]
1851 fn cap_script_io_leaves_small_strings_untouched() {
1852 let s = "small".to_string();
1853 assert_eq!(cap_script_io(s.clone()), s);
1854 }
1855
1856 #[test]
1857 fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1858 let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1859 let capped = cap_script_io(big);
1860 assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1861 assert!(capped.contains("[...truncated by leviath"));
1862 }
1863
1864 #[test]
1865 fn cap_script_io_truncates_on_a_char_boundary() {
1866 let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1868 s.push('é'); s.push_str(&"b".repeat(10));
1870 let capped = cap_script_io(s);
1871 assert!(capped.contains("[...truncated by leviath"));
1873 }
1874}