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) {
749 #[cfg(windows)]
750 {
751 ("cmd.exe", "/C")
752 }
753 #[cfg(not(windows))]
754 {
755 ("/bin/sh", "-c")
756 }
757}
758
759pub(crate) fn host_shell_command(
762 shell: &str,
763 flag: &str,
764 command: &str,
765 workdir: &Path,
766) -> TokioCommand {
767 let mut c = TokioCommand::new(shell);
768 c.arg(flag).arg(command).current_dir(workdir);
769 c
770}
771
772pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
775 let mut out = String::from_utf8_lossy(stdout).into_owned();
776 let err = String::from_utf8_lossy(stderr);
777 if !err.trim().is_empty() {
778 out.push_str(&err);
779 }
780 out
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use std::sync::Mutex;
787
788 fn perms(all: ScriptPermission) -> ScriptToolPermissions {
791 ScriptToolPermissions {
792 http_get: all,
793 http_post: all,
794 shell: all,
795 read_file: all,
796 write_file: all,
797 env_var: all,
798 }
799 }
800
801 #[test]
802 fn resolve_allow_permits_everything() {
803 let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
804 assert_eq!(
805 a,
806 ScriptAllow {
807 http_get: true,
808 http_post: true,
809 shell: true,
810 read_file: true,
811 write_file: true,
812 env_var: true,
813 }
814 );
815 }
816
817 #[test]
818 fn resolve_deny_blocks_everything() {
819 let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
820 assert_eq!(
821 a,
822 ScriptAllow {
823 http_get: false,
824 http_post: false,
825 shell: false,
826 read_file: false,
827 write_file: false,
828 env_var: false,
829 }
830 );
831 }
832
833 #[test]
834 fn resolve_inherit_net_true_filelike_follows_builtin() {
835 let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
837 "read_file" => ToolPolicy::Allow,
838 _ => ToolPolicy::Ask,
839 });
840 assert!(a.http_get && a.http_post && a.env_var);
841 assert!(a.read_file, "read_file inherit → Allow");
842 assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
843 assert!(!a.shell, "shell inherit → Ask ⇒ denied");
844 }
845
846 #[test]
849 fn effective_perms_agent_tightens_per_field() {
850 let global = perms(ScriptPermission::Allow);
854 let manifest = "\
855 [tool_script_permissions]\n\
856 http_get = \"allow\"\n\
857 shell = \"deny\"\n\
858 write_file = \"inherit\"\n";
859 let eff = effective_script_permissions(&global, manifest);
860 assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
861 assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
862 assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
863 assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
864 assert_eq!(eff.read_file, ScriptPermission::Allow);
865 assert_eq!(eff.http_post, ScriptPermission::Allow);
866 }
867
868 #[test]
873 fn effective_perms_agent_cannot_loosen_global() {
874 let global = perms(ScriptPermission::Deny);
875 let manifest = "\
876 [tool_script_permissions]\n\
877 http_get = \"allow\"\n\
878 shell = \"allow\"\n\
879 env_var = \"inherit\"\n";
880 let eff = effective_script_permissions(&global, manifest);
881 assert_eq!(eff.http_get, ScriptPermission::Deny);
882 assert_eq!(eff.shell, ScriptPermission::Deny);
883 assert_eq!(eff.env_var, ScriptPermission::Deny);
884 }
885
886 #[test]
889 fn effective_perms_agent_cannot_promote_inherit_to_allow() {
890 let global = perms(ScriptPermission::Inherit);
891 let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
892 let eff = effective_script_permissions(&global, manifest);
893 assert_eq!(eff.shell, ScriptPermission::Inherit);
894 }
895
896 #[test]
897 fn effective_perms_absent_section_keeps_global() {
898 let global = perms(ScriptPermission::Deny);
899 let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
901 assert_eq!(eff.shell, ScriptPermission::Deny);
902 assert_eq!(eff.http_get, ScriptPermission::Deny);
903 }
904
905 #[test]
906 fn effective_perms_malformed_inputs_fall_back_to_global() {
907 let global = perms(ScriptPermission::Allow);
908 let eff = effective_script_permissions(&global, "not = valid = toml");
910 assert_eq!(eff.shell, ScriptPermission::Allow);
911 let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
913 assert_eq!(eff2.shell, ScriptPermission::Allow);
914 let eff3 =
916 effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
917 assert_eq!(eff3.shell, ScriptPermission::Allow);
918 }
919
920 struct RecordingIo {
923 calls: Mutex<Vec<String>>,
924 }
925 impl RecordingIo {
926 fn arc() -> Arc<RecordingIo> {
927 Arc::new(RecordingIo {
928 calls: Mutex::new(Vec::new()),
929 })
930 }
931 }
932 impl ScriptIo for RecordingIo {
933 fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
934 self.calls.lock().unwrap().push(format!("get:{url}"));
935 Ok("g".into())
936 }
937 fn http_post(
938 &self,
939 url: &str,
940 body: &str,
941 _h: BTreeMap<String, String>,
942 ) -> Result<String, String> {
943 self.calls
944 .lock()
945 .unwrap()
946 .push(format!("post:{url}:{body}"));
947 Ok("p".into())
948 }
949 fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
950 let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
952 self.calls.lock().unwrap().push(format!("shell:{prog}"));
953 Ok("s".into())
954 }
955 fn read_file(&self, path: &Path) -> Result<String, String> {
956 self.calls
957 .lock()
958 .unwrap()
959 .push(format!("read:{}", path.display()));
960 Ok("r".into())
961 }
962 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
963 self.calls
964 .lock()
965 .unwrap()
966 .push(format!("write:{}:{content}", path.display()));
967 Ok("w".into())
968 }
969 fn env_var(&self, name: &str) -> Result<String, String> {
970 self.calls.lock().unwrap().push(format!("env:{name}"));
971 Ok("e".into())
972 }
973 }
974
975 fn all_allowed() -> ScriptAllow {
976 ScriptAllow {
977 http_get: true,
978 http_post: true,
979 shell: true,
980 read_file: true,
981 write_file: true,
982 env_var: true,
983 }
984 }
985
986 fn none_allowed() -> ScriptAllow {
987 ScriptAllow {
988 http_get: false,
989 http_post: false,
990 shell: false,
991 read_file: false,
992 write_file: false,
993 env_var: false,
994 }
995 }
996
997 #[test]
998 fn script_write_refuses_a_deleted_workspace() {
999 let dir = tempfile::tempdir().unwrap();
1002 let workdir = dir.path().join("gone");
1003 let io = RecordingIo::arc();
1004 let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1005 let err = host.write_file("out.txt", "body").unwrap_err();
1006 assert!(err.contains("no longer accessible"), "got: {err}");
1007 assert!(
1008 io.calls.lock().unwrap().is_empty(),
1009 "the io layer never ran"
1010 );
1011 std::fs::create_dir(&workdir).unwrap();
1013 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1014 }
1015
1016 const PUBLIC_URL: &str = "http://93.184.216.34/";
1020
1021 #[test]
1022 fn allowed_calls_delegate_to_io() {
1023 let io = RecordingIo::arc();
1024 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1025 assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1026 assert_eq!(
1027 host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1028 "p"
1029 );
1030 assert_eq!(host.shell("ls").unwrap(), "s");
1031 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1032 assert_eq!(host.env_var("HOME").unwrap(), "e");
1033 let calls = io.calls.lock().unwrap().clone();
1034 assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1035 assert!(calls.iter().any(|c| c.starts_with("post:")));
1036 assert!(calls.iter().any(|c| c.starts_with("shell:")));
1038 assert!(
1039 calls
1040 .iter()
1041 .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1042 );
1043 assert!(calls.contains(&"env:HOME".to_string()));
1044 }
1045
1046 #[test]
1050 fn outbound_check_blocks_local_targets_before_any_io() {
1051 let io = RecordingIo::arc();
1052 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1053 for url in [
1054 "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1056 "http://127.0.0.1:3000/api/agents",
1058 "http://192.168.1.1/",
1060 "file:///etc/passwd",
1062 ] {
1063 let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1064 assert!(err.starts_with("[denied]"), "{url} → {err}");
1065 let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1066 assert!(err.starts_with("[denied]"), "{url} → {err}");
1067 }
1068 let calls = io.calls.lock().unwrap().clone();
1069 assert!(
1070 calls.is_empty(),
1071 "a refused URL must never reach the I/O backend: {calls:?}"
1072 );
1073 }
1074
1075 #[test]
1080 fn env_var_refuses_credential_names_by_default() {
1081 let io = RecordingIo::arc();
1082 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1083 for name in [
1084 "ANTHROPIC_API_KEY",
1085 "OPENAI_API_KEY",
1086 "AWS_SECRET_ACCESS_KEY",
1087 "GITHUB_TOKEN",
1088 "LEVIATH_API_TOKEN",
1089 ] {
1090 let err = host.env_var(name).unwrap_err();
1091 assert!(err.starts_with("[denied]"), "{name} → {err}");
1092 assert!(err.contains("allow_env_vars"), "{name} → {err}");
1093 }
1094 assert!(
1095 io.calls.lock().unwrap().is_empty(),
1096 "a refused read must never reach the I/O backend"
1097 );
1098 }
1099
1100 #[test]
1103 fn env_var_allows_ordinary_names() {
1104 let io = RecordingIo::arc();
1105 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1106 assert_eq!(host.env_var("PATH").unwrap(), "e");
1107 assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1108 }
1109
1110 #[test]
1113 fn env_var_allowlist_permits_exactly_the_named_variable() {
1114 let io = RecordingIo::arc();
1115 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1116 .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1117 assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1118 assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1119 }
1120
1121 #[test]
1124 fn outbound_check_rejects_unparseable_urls() {
1125 let io = RecordingIo::arc();
1126 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1127 let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1128 assert!(err.contains("invalid URL"), "{err}");
1129 assert!(io.calls.lock().unwrap().is_empty());
1130 }
1131
1132 #[test]
1136 fn allow_local_network_opens_the_local_path() {
1137 let io = RecordingIo::arc();
1138 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1139 .with_local_network(true);
1140 assert_eq!(
1141 host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1142 .unwrap(),
1143 "g"
1144 );
1145 assert!(
1147 host.http_get("file:///etc/passwd", BTreeMap::new())
1148 .is_err()
1149 );
1150 }
1151
1152 static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1157
1158 fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1160 REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1161 }
1162
1163 #[test]
1166 fn redirect_switch_is_independent_of_the_host_field() {
1167 let _guard = lock_redirect_mirror();
1168 let io = RecordingIo::arc();
1169 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1170 let previous = local_network_allowed();
1171 set_local_network_allowed(true);
1172 let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1173 set_local_network_allowed(previous);
1174 assert!(
1175 decided.is_err(),
1176 "the host field, not the redirect mirror, decides the initial URL"
1177 );
1178 }
1179
1180 #[test]
1181 fn denied_calls_return_denied_and_skip_io() {
1182 let io = RecordingIo::arc();
1183 let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1184 assert!(
1185 host.http_get("http://x", BTreeMap::new())
1186 .unwrap_err()
1187 .contains("[denied]")
1188 );
1189 assert!(
1190 host.http_post("http://x", "b", BTreeMap::new())
1191 .unwrap_err()
1192 .contains("http_post")
1193 );
1194 assert!(host.shell("ls").unwrap_err().contains("shell"));
1195 assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1196 assert!(
1197 host.write_file("a.txt", "b")
1198 .unwrap_err()
1199 .contains("write_file")
1200 );
1201 assert!(host.env_var("X").unwrap_err().contains("env_var"));
1202 assert!(
1203 io.calls.lock().unwrap().is_empty(),
1204 "no I/O on denied calls"
1205 );
1206 }
1207
1208 #[test]
1209 fn read_file_confined_to_workdir() {
1210 let dir = tempfile::tempdir().unwrap();
1211 std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1212 let io = RecordingIo::arc();
1213 let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1214 assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1216 assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1217 let err = host.read_file("../../etc/passwd").unwrap_err();
1220 assert!(err.contains("escape"));
1221 let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1222 assert!(werr.contains("escape"));
1223 let calls = io.calls.lock().unwrap().clone();
1225 assert_eq!(calls.len(), 2);
1226 assert!(calls.iter().any(|c| c.starts_with("read:")));
1227 assert!(calls.iter().any(|c| c.starts_with("write:")));
1228 }
1229
1230 #[test]
1231 fn read_file_absolute_outside_workdir_rejected() {
1232 let dir = tempfile::tempdir().unwrap();
1233 let host =
1234 DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1235 let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1240 assert!(outside.is_absolute(), "test path must be absolute");
1241 let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1242 assert!(err.contains("would escape"), "got: {err}");
1243 }
1244
1245 #[test]
1246 fn read_file_pop_past_root_rejected() {
1247 let host =
1251 DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1252 let err = host.read_file("../..").unwrap_err();
1253 assert!(err.contains("escapes the working directory"), "got: {err}");
1254 }
1255
1256 async fn mock_http() -> String {
1259 use axum::Router;
1260 use axum::routing::{get, post};
1261 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1262 let base = format!("http://{}", listener.local_addr().unwrap());
1263 let app = Router::new()
1264 .route("/ok", get(|| async { "GET-BODY" }))
1265 .route("/echo", post(|body: String| async move { body }))
1266 .route(
1267 "/boom",
1268 get(|| async {
1269 (
1270 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1271 "server error",
1272 )
1273 }),
1274 )
1275 .route(
1278 "/png",
1279 get(|| async {
1280 (
1281 [(axum::http::header::CONTENT_TYPE, "image/png")],
1282 vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1284 )
1285 }),
1286 )
1287 .route(
1290 "/shiftjis",
1291 get(|| async {
1292 (
1293 [(
1294 axum::http::header::CONTENT_TYPE,
1295 "text/html; charset=shift_jis",
1296 )],
1297 vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1299 )
1300 }),
1301 );
1302 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1303 listener, app,
1304 )));
1305 base
1306 }
1307
1308 #[test]
1309 fn binary_content_types_are_classified_but_structured_text_is_not() {
1310 for text in [
1311 "",
1312 "text/html; charset=utf-8",
1313 "text/plain",
1314 "application/json",
1315 "application/xml",
1316 "application/xhtml+xml",
1317 "application/ld+json",
1318 "application/javascript",
1319 ] {
1320 assert!(!is_binary_content_type(text), "should be text: {text:?}");
1321 }
1322 for binary in [
1323 "image/png",
1324 "IMAGE/PNG",
1325 "image/jpeg; charset=binary",
1326 " audio/mpeg ",
1327 "video/mp4",
1328 "font/woff2",
1329 "application/octet-stream",
1330 "application/pdf",
1331 "application/zip",
1332 "application/gzip",
1333 "application/x-tar",
1334 "application/x-bzip2",
1335 "application/wasm",
1336 "application/vnd.ms-excel",
1337 "application/msword",
1338 ] {
1339 assert!(
1340 is_binary_content_type(binary),
1341 "should be binary: {binary:?}"
1342 );
1343 }
1344 }
1345
1346 #[test]
1347 fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1348 let with_len = non_text_body_message("image/png", Some(2049));
1349 assert!(with_len.contains("image/png"), "got: {with_len}");
1350 assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1351 let without_len = non_text_body_message("audio/mpeg", None);
1352 assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1353 assert!(
1354 !without_len.contains("KB"),
1355 "no size to report: {without_len}"
1356 );
1357 }
1358
1359 #[tokio::test(flavor = "multi_thread")]
1360 async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1361 let base = mock_http().await;
1362 let (png, sjis) = tokio::task::spawn_blocking(move || {
1363 (
1364 RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1365 RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1366 )
1367 })
1368 .await
1369 .unwrap();
1370
1371 let err = png.unwrap_err();
1373 assert!(err.contains("non-text content"), "got: {err}");
1374 assert!(err.contains("image/png"), "got: {err}");
1375
1376 assert_eq!(sjis.unwrap(), "日本語");
1379 }
1380
1381 #[test]
1385 fn oversized_declared_body_is_refused() {
1386 let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1387 assert!(msg.contains("999999999"), "{msg}");
1388 assert!(msg.contains("1000-byte limit"), "{msg}");
1389 }
1390
1391 #[test]
1395 fn body_within_cap_or_of_unknown_size_proceeds() {
1396 assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1397 assert!(oversized_body_message(Some(0), 1_000).is_none());
1398 assert!(oversized_body_message(None, 1_000).is_none());
1399 }
1400
1401 #[tokio::test(flavor = "multi_thread")]
1405 async fn send_refuses_a_body_over_the_cap() {
1406 let base = mock_http().await;
1407 let out = tokio::task::spawn_blocking(move || {
1408 let client = RealScriptIo::client();
1409 RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1411 })
1412 .await
1413 .unwrap();
1414 let err = out.expect_err("a body over the cap is refused");
1415 assert!(err.contains("over the"), "got: {err}");
1416 }
1417
1418 #[tokio::test(flavor = "multi_thread")]
1423 async fn redirects_to_a_local_address_are_refused() {
1424 use axum::Router;
1425 use axum::response::Redirect;
1426 use axum::routing::get;
1427
1428 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1429 let addr = listener.local_addr().unwrap();
1430 let app = Router::new().route(
1434 "/bounce",
1435 get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1436 );
1437 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1438 listener, app,
1439 )));
1440
1441 let out = tokio::task::spawn_blocking(move || {
1445 let _guard = lock_redirect_mirror();
1446 let previous = local_network_allowed();
1447 set_local_network_allowed(false);
1448 let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1449 set_local_network_allowed(previous);
1450 result
1451 })
1452 .await
1453 .unwrap();
1454 let err = out.expect_err("a redirect to loopback must not be followed");
1455 assert!(err.contains("refused to follow redirect"), "got: {err}");
1456 }
1457
1458 #[tokio::test(flavor = "multi_thread")]
1461 async fn a_redirect_loop_is_bounded() {
1462 use axum::Router;
1463 use axum::response::Redirect;
1464 use axum::routing::get;
1465
1466 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1467 let addr = listener.local_addr().unwrap();
1468 let app = Router::new().route(
1469 "/loop",
1470 get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1471 );
1472 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1473 listener, app,
1474 )));
1475
1476 let out = tokio::task::spawn_blocking(move || {
1477 let _guard = lock_redirect_mirror();
1478 let previous = local_network_allowed();
1480 set_local_network_allowed(true);
1481 let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1482 set_local_network_allowed(previous);
1483 result
1484 })
1485 .await
1486 .unwrap();
1487 let err = out.expect_err("an endless redirect must be stopped");
1488 assert!(err.contains("too many redirects"), "got: {err}");
1489 }
1490
1491 #[test]
1496 fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1497 fn escapes(_: &Path, _: &Path) -> bool {
1498 false
1499 }
1500 let dir = tempfile::tempdir().unwrap();
1501 let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1502 .expect_err("a path that resolves outside must be refused");
1503 assert!(err.contains("symlink"), "{err}");
1504 }
1505
1506 #[test]
1509 fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1510 let dir = tempfile::tempdir().unwrap();
1511 let resolved =
1512 DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1513 .expect("an ordinary path resolves");
1514 assert!(resolved.ends_with("notes.txt"));
1515 }
1516
1517 #[cfg(unix)]
1520 #[test]
1521 fn script_host_read_refuses_a_symlink_escape() {
1522 let dir = tempfile::tempdir().unwrap();
1523 let workdir = dir.path().join("workspace");
1524 std::fs::create_dir(&workdir).unwrap();
1525 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1526
1527 let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1528 let err = host.read_file("link/etc/hosts").unwrap_err();
1529 assert!(err.contains("symlink"), "got: {err}");
1530 }
1531
1532 #[tokio::test(flavor = "multi_thread")]
1533 async fn real_http_get_success_and_headers() {
1534 let base = mock_http().await;
1535 let out = tokio::task::spawn_blocking(move || {
1536 let mut h = BTreeMap::new();
1537 h.insert("X-Test".to_string(), "1".to_string());
1538 RealScriptIo.http_get(&format!("{base}/ok"), h)
1539 })
1540 .await
1541 .unwrap();
1542 assert_eq!(out.unwrap(), "GET-BODY");
1543 }
1544
1545 #[tokio::test(flavor = "multi_thread")]
1546 async fn real_http_get_non_success_is_error() {
1547 let base = mock_http().await;
1548 let out = tokio::task::spawn_blocking(move || {
1549 RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1550 })
1551 .await
1552 .unwrap();
1553 let err = out.unwrap_err();
1554 assert!(
1555 err.contains("http 500") && err.contains("server error"),
1556 "got: {err}"
1557 );
1558 }
1559
1560 #[tokio::test(flavor = "multi_thread")]
1561 async fn real_http_get_connection_error() {
1562 let out = tokio::task::spawn_blocking(|| {
1564 RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1565 })
1566 .await
1567 .unwrap();
1568 assert!(out.unwrap_err().contains("request failed"));
1569 }
1570
1571 async fn spawn_truncated_body_server() -> String {
1575 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1576 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1577 let addr = listener.local_addr().unwrap();
1578 let body = b"partial";
1579 let response = format!(
1580 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1581 body.len() + 4096
1582 )
1583 .into_bytes();
1584 tokio::spawn(async move {
1585 let (mut socket, _) = listener.accept().await.unwrap();
1586 let mut buf = [0u8; 8192];
1587 let _ = socket.read(&mut buf).await;
1588 let _ = socket.write_all(&response).await;
1589 let _ = socket.write_all(body).await;
1590 let _ = socket.flush().await;
1591 let _ = socket.shutdown().await;
1592 });
1593 format!("http://{addr}")
1594 }
1595
1596 #[tokio::test(flavor = "multi_thread")]
1597 async fn real_http_body_read_error() {
1598 let base = spawn_truncated_body_server().await;
1599 let out = tokio::task::spawn_blocking(move || {
1600 RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1601 })
1602 .await
1603 .unwrap();
1604 let err = out.unwrap_err();
1605 assert!(err.contains("read body"), "got: {err}");
1606 }
1607
1608 #[tokio::test(flavor = "multi_thread")]
1609 async fn real_http_post_echoes_body() {
1610 let base = mock_http().await;
1611 let out = tokio::task::spawn_blocking(move || {
1612 RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1613 })
1614 .await
1615 .unwrap();
1616 assert_eq!(out.unwrap(), "hello");
1617 }
1618
1619 async fn run_host_shell(
1622 command: &'static str,
1623 workdir: PathBuf,
1624 timeout: Duration,
1625 ) -> Result<String, String> {
1626 tokio::task::spawn_blocking(move || {
1627 let (shell, flag) = default_shell();
1628 let cmd = host_shell_command(shell, flag, command, &workdir);
1629 RealScriptIo.run_shell(cmd, timeout)
1630 })
1631 .await
1632 .unwrap()
1633 }
1634
1635 #[test]
1636 fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1637 let dir = tempfile::tempdir().unwrap();
1642 let workdir = dir.path().to_path_buf();
1643 let err = std::thread::spawn(move || {
1644 let (shell, flag) = default_shell();
1645 let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1646 RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1647 })
1648 .join()
1649 .unwrap()
1650 .unwrap_err();
1651 assert!(err.contains("no tokio runtime"), "got: {err}");
1652 }
1653
1654 #[tokio::test(flavor = "multi_thread")]
1655 async fn real_shell_runs_and_captures_output() {
1656 let dir = tempfile::tempdir().unwrap();
1657 let out = run_host_shell(
1659 "echo hello",
1660 dir.path().to_path_buf(),
1661 Duration::from_secs(30),
1662 )
1663 .await
1664 .unwrap();
1665 assert!(out.contains("hello"));
1666 let out2 = run_host_shell(
1668 "echo oops 1>&2",
1669 dir.path().to_path_buf(),
1670 Duration::from_secs(30),
1671 )
1672 .await
1673 .unwrap();
1674 assert!(out2.contains("oops"));
1675 }
1676
1677 #[tokio::test(flavor = "multi_thread")]
1678 async fn real_shell_spawn_failure() {
1679 let missing = PathBuf::from("/no/such/workdir/leviath");
1681 let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1682 .await
1683 .unwrap_err();
1684 assert!(err.contains("failed to spawn shell"), "got: {err}");
1685 }
1686
1687 #[tokio::test(flavor = "multi_thread")]
1688 async fn real_shell_times_out() {
1689 let dir = tempfile::tempdir().unwrap();
1691 let err = run_host_shell(
1692 "sleep 5",
1693 dir.path().to_path_buf(),
1694 Duration::from_millis(50),
1695 )
1696 .await
1697 .unwrap_err();
1698 assert!(err.contains("timed out"), "got: {err}");
1699 }
1700
1701 #[test]
1702 fn combine_shell_output_appends_nonempty_stderr_only() {
1703 assert_eq!(combine_shell_output(b"out", b" "), "out");
1705 assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1706 }
1707
1708 #[test]
1709 fn host_shell_command_targets_workdir() {
1710 let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1711 assert_eq!(cmd.as_std().get_program(), "sh");
1712 }
1713
1714 #[test]
1715 fn shell_routes_through_sandbox_when_present() {
1716 use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1717 let by_index = vec![ToolSandboxConfig {
1721 kind: SandboxKind::Namespace,
1722 on_unavailable: OnUnavailable::Warn,
1723 ..Default::default()
1724 }];
1725 let sb = SandboxManager::build("r", by_index, "/w", 0)
1726 .unwrap()
1727 .map(Arc::new);
1728 assert!(sb.is_some(), "namespace warn config yields a manager");
1729 let io = RecordingIo::arc();
1730 let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1731 .with_shell(sb, Duration::from_secs(5));
1732 assert_eq!(host.shell("ls").unwrap(), "s");
1733 assert!(
1734 io.calls
1735 .lock()
1736 .unwrap()
1737 .iter()
1738 .any(|c| c.starts_with("shell:"))
1739 );
1740 }
1741
1742 #[test]
1743 fn real_read_file_success_and_error() {
1744 let dir = tempfile::tempdir().unwrap();
1745 let p = dir.path().join("f.txt");
1746 std::fs::write(&p, "data").unwrap();
1747 assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1748 let err = RealScriptIo
1749 .read_file(&dir.path().join("nope"))
1750 .unwrap_err();
1751 assert!(err.contains("read '"));
1752 }
1753
1754 #[test]
1755 fn real_write_file_creates_parents_and_reports() {
1756 let dir = tempfile::tempdir().unwrap();
1757 let nested = dir.path().join("sub/deep/out.txt");
1759 let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1760 assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1761 assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1762 }
1763
1764 #[test]
1765 fn real_write_file_create_dir_error() {
1766 let dir = tempfile::tempdir().unwrap();
1767 let blocker = dir.path().join("afile");
1769 std::fs::write(&blocker, "x").unwrap();
1770 let err = RealScriptIo
1771 .write_file(&blocker.join("child.txt"), "b")
1772 .unwrap_err();
1773 assert!(err.contains("create dir"), "got: {err}");
1774 }
1775
1776 #[test]
1777 fn real_write_file_write_error() {
1778 let dir = tempfile::tempdir().unwrap();
1779 let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1781 assert!(err.contains("write '"), "got: {err}");
1782 }
1783
1784 #[test]
1785 fn real_write_file_parentless_path() {
1786 let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1789 assert!(err.contains("write '"), "got: {err}");
1790 }
1791
1792 #[test]
1793 fn real_env_var_set_and_unset() {
1794 temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1795 assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1796 });
1797 temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1798 assert!(
1799 RealScriptIo
1800 .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1801 .unwrap_err()
1802 .contains("not set")
1803 );
1804 });
1805 }
1806
1807 #[test]
1808 fn default_shell_is_platform_appropriate() {
1809 let (shell, flag) = default_shell();
1810 assert!(!shell.is_empty());
1811 assert!(!flag.is_empty());
1812 }
1813
1814 #[test]
1815 fn new_wires_real_io() {
1816 let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1818 temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1820 assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1821 });
1822 }
1823
1824 #[test]
1825 fn cap_script_io_leaves_small_strings_untouched() {
1826 let s = "small".to_string();
1827 assert_eq!(cap_script_io(s.clone()), s);
1828 }
1829
1830 #[test]
1831 fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1832 let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1833 let capped = cap_script_io(big);
1834 assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1835 assert!(capped.contains("[...truncated by leviath"));
1836 }
1837
1838 #[test]
1839 fn cap_script_io_truncates_on_a_char_boundary() {
1840 let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1842 s.push('é'); s.push_str(&"b".repeat(10));
1844 let capped = cap_script_io(s);
1845 assert!(capped.contains("[...truncated by leviath"));
1847 }
1848}