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 shell_env: leviath_tools::ShellEnvPolicy,
213}
214
215impl DaemonScriptHost {
216 pub fn with_io(allow: ScriptAllow, workdir: PathBuf, io: Arc<dyn ScriptIo>) -> Self {
220 Self {
221 allow,
222 workdir,
223 io,
224 sandbox: None,
225 shell_timeout: Duration::from_secs(60),
226 allow_local_network: false,
227 allow_env_vars: Vec::new(),
228 shell_env: leviath_tools::ShellEnvPolicy::default(),
229 }
230 }
231
232 pub fn with_local_network(mut self, allow: bool) -> Self {
235 self.allow_local_network = allow;
236 self
237 }
238
239 pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
242 self.allow_env_vars = names;
243 self
244 }
245
246 pub fn new(allow: ScriptAllow, workdir: PathBuf) -> Self {
248 Self::with_io(allow, workdir, Arc::new(RealScriptIo))
249 }
250
251 pub fn with_shell(
254 mut self,
255 sandbox: Option<Arc<SandboxManager>>,
256 shell_timeout: Duration,
257 shell_env: leviath_tools::ShellEnvPolicy,
258 ) -> Self {
259 self.sandbox = sandbox;
260 self.shell_timeout = shell_timeout;
261 self.shell_env = shell_env;
262 self
263 }
264
265 fn resolve_in_workdir(&self, requested: &str) -> Result<PathBuf, String> {
269 Self::resolve_in(requested, &self.workdir, leviath_core::resolves_within)
270 }
271
272 fn resolve_in(
282 requested: &str,
283 workdir: &Path,
284 within: fn(&Path, &Path) -> bool,
285 ) -> Result<PathBuf, String> {
286 if leviath_tools::is_null_device(requested) {
290 return Ok(PathBuf::from(requested));
291 }
292 let raw = if Path::new(requested).is_absolute() {
293 PathBuf::from(requested)
294 } else {
295 workdir.join(requested)
296 };
297 let mut normalized = PathBuf::new();
298 for component in raw.components() {
299 match component {
300 Component::ParentDir => {
301 if !normalized.pop() {
302 return Err(format!("path '{requested}' escapes the working directory"));
303 }
304 }
305 c => normalized.push(c),
306 }
307 }
308 if !normalized.starts_with(workdir) {
309 return Err(format!(
310 "path '{requested}' would escape the working directory ({}). \
311 Use a path inside the workspace instead - a relative path \
312 resolves against it.",
313 workdir.display()
314 ));
315 }
316 if !within(&normalized, workdir) {
319 return Err(format!(
320 "path '{requested}' resolves outside the working directory through a symlink"
321 ));
322 }
323 Ok(normalized)
324 }
325}
326
327fn denied(func: &str) -> String {
330 format!("[denied] script host function '{func}' is denied by tool_script_permissions")
331}
332
333fn check_outbound(url: &str, allow_local: bool) -> Result<(), String> {
344 let parsed = url::Url::parse(url).map_err(|e| format!("[denied] invalid URL '{url}': {e}"))?;
345 leviath_net::check_url(&parsed, allow_local).map_err(|e| format!("[denied] {e}"))
346}
347
348impl ScriptHost for DaemonScriptHost {
349 fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
350 if !self.allow.http_get {
351 return Err(denied("http_get"));
352 }
353 check_outbound(url, self.allow_local_network)?;
354 self.io.http_get(url, headers)
355 }
356
357 fn http_post(
358 &self,
359 url: &str,
360 body: &str,
361 headers: BTreeMap<String, String>,
362 ) -> Result<String, String> {
363 if !self.allow.http_post {
364 return Err(denied("http_post"));
365 }
366 check_outbound(url, self.allow_local_network)?;
367 self.io.http_post(url, body, headers)
368 }
369
370 fn shell(&self, command: &str) -> Result<String, String> {
371 if !self.allow.shell {
372 return Err(denied("shell"));
373 }
374 if !self.allow.write_file && crate::shell_keys::writes_a_file(command) {
381 return Err(denied("write_file (a shell redirect writes a file)"));
382 }
383 if let Some(refusal) = crate::tools::escaping_write_refusal(
386 "shell",
387 &serde_json::json!({ "command": command }),
388 &self.workdir,
389 ) {
390 return Err(refusal);
391 }
392 let (shell, flag) = default_shell();
393 let mut cmd = match &self.sandbox {
397 Some(sb) => sb.build_command(shell, flag, command, &self.workdir),
398 None => host_shell_command(shell, flag, command, &self.workdir),
399 };
400 self.shell_env.apply(&mut cmd);
403 self.io.run_shell(cmd, self.shell_timeout)
404 }
405
406 fn read_file(&self, path: &str) -> Result<String, String> {
407 if !self.allow.read_file {
408 return Err(denied("read_file"));
409 }
410 let resolved = self.resolve_in_workdir(path)?;
411 self.io.read_file(&resolved)
412 }
413
414 fn write_file(&self, path: &str, content: &str) -> Result<String, String> {
415 if !self.allow.write_file {
416 return Err(denied("write_file"));
417 }
418 if !std::fs::metadata(&self.workdir).is_ok_and(|m| m.is_dir()) {
421 return Err(format!(
422 "workspace '{}' is no longer accessible",
423 self.workdir.display()
424 ));
425 }
426 let resolved = self.resolve_in_workdir(path)?;
427 self.io.write_file(&resolved, content)
428 }
429
430 fn env_var(&self, name: &str) -> Result<String, String> {
431 if !self.allow.env_var {
432 return Err(denied("env_var"));
433 }
434 if !leviath_core::script_env_allowed(name, &self.allow_env_vars) {
440 return Err(format!(
441 "[denied] '{name}' looks like a credential. Add it to `[security] \
442 allow_env_vars` in ~/.leviath/config.toml if this agent is meant \
443 to read it."
444 ));
445 }
446 self.io.env_var(name)
447 }
448}
449
450pub struct RealScriptIo;
456
457static HTTP_CLIENT: std::sync::LazyLock<reqwest::blocking::Client> =
471 std::sync::LazyLock::new(|| {
472 reqwest::blocking::Client::builder()
473 .timeout(Duration::from_secs(30))
474 .redirect(reqwest::redirect::Policy::custom(|attempt| {
480 if attempt.previous().len() >= 5 {
481 return attempt.error("too many redirects");
482 }
483 match leviath_net::check_url(attempt.url(), local_network_allowed()) {
484 Ok(()) => attempt.follow(),
485 Err(e) => attempt.error(format!("refused to follow redirect: {e}")),
486 }
487 }))
488 .build()
489 .expect("failed to build blocking reqwest client")
490 });
491
492fn error_chain(e: &dyn std::error::Error) -> String {
500 let mut parts = vec![e.to_string()];
501 let mut source = e.source();
502 while let Some(err) = source {
503 parts.push(err.to_string());
504 source = err.source();
505 }
506 parts.join(": ")
507}
508
509static ALLOW_LOCAL_REDIRECTS: std::sync::atomic::AtomicBool =
523 std::sync::atomic::AtomicBool::new(false);
524
525pub fn set_local_network_allowed(allow: bool) {
527 ALLOW_LOCAL_REDIRECTS.store(allow, std::sync::atomic::Ordering::Relaxed);
528}
529
530fn local_network_allowed() -> bool {
532 ALLOW_LOCAL_REDIRECTS.load(std::sync::atomic::Ordering::Relaxed)
533}
534
535impl RealScriptIo {
536 fn client() -> reqwest::blocking::Client {
539 HTTP_CLIENT.clone()
540 }
541
542 fn with_headers(
544 mut req: reqwest::blocking::RequestBuilder,
545 headers: BTreeMap<String, String>,
546 ) -> reqwest::blocking::RequestBuilder {
547 for (k, v) in headers {
548 req = req.header(k, v);
549 }
550 req
551 }
552
553 fn send(req: reqwest::blocking::RequestBuilder) -> Result<String, String> {
560 Self::send_capped(req, MAX_RESPONSE_BYTES)
561 }
562
563 fn send_capped(req: reqwest::blocking::RequestBuilder, max: u64) -> Result<String, String> {
566 let resp = req
567 .send()
568 .map_err(|e| format!("request failed: {}", error_chain(&e)))?;
569 let status = resp.status();
570 let content_type = resp
571 .headers()
572 .get(reqwest::header::CONTENT_TYPE)
573 .and_then(|v| v.to_str().ok())
574 .unwrap_or_default()
575 .to_string();
576 if is_binary_content_type(&content_type) {
577 let len = resp.content_length();
578 return Err(non_text_body_message(&content_type, len));
579 }
580 if let Some(msg) = oversized_body_message(resp.content_length(), max) {
591 return Err(msg);
592 }
593 let text = cap_script_io(resp.text().map_err(|e| format!("read body: {e}"))?);
594 if status.is_success() {
595 Ok(text)
596 } else {
597 Err(format!("http {status}: {text}"))
598 }
599 }
600}
601
602const BINARY_CONTENT_PREFIXES: &[&str] = &[
611 "image/",
612 "audio/",
613 "video/",
614 "font/",
615 "application/octet-stream",
616 "application/pdf",
617 "application/zip",
618 "application/gzip",
619 "application/x-tar",
620 "application/x-bzip",
621 "application/wasm",
622 "application/vnd.",
623 "application/msword",
624];
625
626fn is_binary_content_type(content_type: &str) -> bool {
628 let essence = content_type
630 .split(';')
631 .next()
632 .unwrap_or_default()
633 .trim()
634 .to_ascii_lowercase();
635 BINARY_CONTENT_PREFIXES
638 .iter()
639 .any(|prefix| essence.starts_with(prefix))
640}
641
642fn non_text_body_message(content_type: &str, len: Option<u64>) -> String {
645 let size = match len {
646 Some(bytes) => format!(", {} KB", bytes.div_ceil(1024)),
647 None => String::new(),
648 };
649 format!("non-text content ({content_type}{size}) - this tool returns text only")
650}
651
652const MAX_SCRIPT_IO_BYTES: usize = 900_000;
658
659const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
667
668fn oversized_body_message(content_length: Option<u64>, max: u64) -> Option<String> {
676 match content_length {
677 Some(len) if len > max => Some(format!(
678 "response declares {len} bytes, over the {max}-byte limit - \
679 fetch a more specific page"
680 )),
681 _ => None,
682 }
683}
684
685pub(crate) fn cap_script_io(mut s: String) -> String {
686 if s.len() > MAX_SCRIPT_IO_BYTES {
687 s.truncate(floor_char_boundary(&s, MAX_SCRIPT_IO_BYTES));
690 s.push_str("\n[...truncated by leviath: response exceeded 900 KB]");
691 }
692 s
693}
694
695impl ScriptIo for RealScriptIo {
696 fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
697 let client = Self::client();
698 Self::send(Self::with_headers(client.get(url), headers))
699 }
700
701 fn http_post(
702 &self,
703 url: &str,
704 body: &str,
705 headers: BTreeMap<String, String>,
706 ) -> Result<String, String> {
707 let client = Self::client();
708 Self::send(Self::with_headers(
709 client.post(url).body(body.to_string()),
710 headers,
711 ))
712 }
713
714 fn run_shell(&self, mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
715 let Ok(handle) = tokio::runtime::Handle::try_current() else {
723 return Err("shell is unavailable: no tokio runtime on this thread".to_string());
724 };
725 cmd.kill_on_drop(true);
731 leviath_tools::own_process_group(&mut cmd);
732 cmd.stdout(std::process::Stdio::piped())
735 .stderr(std::process::Stdio::piped());
736 handle.block_on(async move {
737 let run = async {
742 let child = cmd.spawn()?;
743 let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
744 child.wait_with_output().await
745 };
746 match tokio::time::timeout(timeout, run).await {
747 Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
748 &output.stdout,
749 &output.stderr,
750 ))),
751 Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
752 Err(_) => Err(format!(
753 "shell command timed out after {}s",
754 timeout.as_secs()
755 )),
756 }
757 })
758 }
759
760 fn read_file(&self, path: &Path) -> Result<String, String> {
761 std::fs::read_to_string(path)
762 .map(cap_script_io)
763 .map_err(|e| format!("read '{}': {e}", path.display()))
764 }
765
766 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
767 if let Some(parent) = path.parent() {
768 std::fs::create_dir_all(parent)
769 .map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
770 }
771 std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
772 Ok(format!(
773 "wrote {} bytes to {}",
774 content.len(),
775 path.display()
776 ))
777 }
778
779 fn env_var(&self, name: &str) -> Result<String, String> {
780 std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
781 }
782}
783
784pub(crate) fn default_shell() -> (&'static str, &'static str) {
791 default_shell_for(std::env::consts::OS)
792}
793
794pub(crate) fn default_shell_for(os: &str) -> (&'static str, &'static str) {
800 match os {
801 "windows" => ("cmd.exe", "/C"),
802 _ => ("/bin/sh", "-c"),
803 }
804}
805
806pub(crate) fn host_shell_command(
809 shell: &str,
810 flag: &str,
811 command: &str,
812 workdir: &Path,
813) -> TokioCommand {
814 let mut c = leviath_sys::child_command_async(shell);
815 c.arg(flag).arg(command).current_dir(workdir);
816 c
817}
818
819pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
822 let mut out = String::from_utf8_lossy(stdout).into_owned();
823 let err = String::from_utf8_lossy(stderr);
824 if !err.trim().is_empty() {
825 out.push_str(&err);
826 }
827 out
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use std::sync::Mutex;
834
835 fn perms(all: ScriptPermission) -> ScriptToolPermissions {
838 ScriptToolPermissions {
839 http_get: all,
840 http_post: all,
841 shell: all,
842 read_file: all,
843 write_file: all,
844 env_var: all,
845 }
846 }
847
848 #[test]
849 fn resolve_allow_permits_everything() {
850 let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
851 assert_eq!(
852 a,
853 ScriptAllow {
854 http_get: true,
855 http_post: true,
856 shell: true,
857 read_file: true,
858 write_file: true,
859 env_var: true,
860 }
861 );
862 }
863
864 #[test]
865 fn resolve_deny_blocks_everything() {
866 let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
867 assert_eq!(
868 a,
869 ScriptAllow {
870 http_get: false,
871 http_post: false,
872 shell: false,
873 read_file: false,
874 write_file: false,
875 env_var: false,
876 }
877 );
878 }
879
880 #[test]
881 fn resolve_inherit_net_true_filelike_follows_builtin() {
882 let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
884 "read_file" => ToolPolicy::Allow,
885 _ => ToolPolicy::Ask,
886 });
887 assert!(a.http_get && a.http_post && a.env_var);
888 assert!(a.read_file, "read_file inherit → Allow");
889 assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
890 assert!(!a.shell, "shell inherit → Ask ⇒ denied");
891 }
892
893 #[test]
896 fn effective_perms_agent_tightens_per_field() {
897 let global = perms(ScriptPermission::Allow);
901 let manifest = "\
902 [tool_script_permissions]\n\
903 http_get = \"allow\"\n\
904 shell = \"deny\"\n\
905 write_file = \"inherit\"\n";
906 let eff = effective_script_permissions(&global, manifest);
907 assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
908 assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
909 assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
910 assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
911 assert_eq!(eff.read_file, ScriptPermission::Allow);
912 assert_eq!(eff.http_post, ScriptPermission::Allow);
913 }
914
915 #[test]
920 fn effective_perms_agent_cannot_loosen_global() {
921 let global = perms(ScriptPermission::Deny);
922 let manifest = "\
923 [tool_script_permissions]\n\
924 http_get = \"allow\"\n\
925 shell = \"allow\"\n\
926 env_var = \"inherit\"\n";
927 let eff = effective_script_permissions(&global, manifest);
928 assert_eq!(eff.http_get, ScriptPermission::Deny);
929 assert_eq!(eff.shell, ScriptPermission::Deny);
930 assert_eq!(eff.env_var, ScriptPermission::Deny);
931 }
932
933 #[test]
936 fn effective_perms_agent_cannot_promote_inherit_to_allow() {
937 let global = perms(ScriptPermission::Inherit);
938 let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
939 let eff = effective_script_permissions(&global, manifest);
940 assert_eq!(eff.shell, ScriptPermission::Inherit);
941 }
942
943 #[test]
944 fn effective_perms_absent_section_keeps_global() {
945 let global = perms(ScriptPermission::Deny);
946 let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
948 assert_eq!(eff.shell, ScriptPermission::Deny);
949 assert_eq!(eff.http_get, ScriptPermission::Deny);
950 }
951
952 #[test]
953 fn effective_perms_malformed_inputs_fall_back_to_global() {
954 let global = perms(ScriptPermission::Allow);
955 let eff = effective_script_permissions(&global, "not = valid = toml");
957 assert_eq!(eff.shell, ScriptPermission::Allow);
958 let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
960 assert_eq!(eff2.shell, ScriptPermission::Allow);
961 let eff3 =
963 effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
964 assert_eq!(eff3.shell, ScriptPermission::Allow);
965 }
966
967 struct RecordingIo {
970 calls: Mutex<Vec<String>>,
971 }
972 impl RecordingIo {
973 fn arc() -> Arc<RecordingIo> {
974 Arc::new(RecordingIo {
975 calls: Mutex::new(Vec::new()),
976 })
977 }
978 }
979 impl ScriptIo for RecordingIo {
980 fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
981 self.calls.lock().unwrap().push(format!("get:{url}"));
982 Ok("g".into())
983 }
984 fn http_post(
985 &self,
986 url: &str,
987 body: &str,
988 _h: BTreeMap<String, String>,
989 ) -> Result<String, String> {
990 self.calls
991 .lock()
992 .unwrap()
993 .push(format!("post:{url}:{body}"));
994 Ok("p".into())
995 }
996 fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
997 let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
999 self.calls.lock().unwrap().push(format!("shell:{prog}"));
1000 Ok("s".into())
1001 }
1002 fn read_file(&self, path: &Path) -> Result<String, String> {
1003 self.calls
1004 .lock()
1005 .unwrap()
1006 .push(format!("read:{}", path.display()));
1007 Ok("r".into())
1008 }
1009 fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
1010 self.calls
1011 .lock()
1012 .unwrap()
1013 .push(format!("write:{}:{content}", path.display()));
1014 Ok("w".into())
1015 }
1016 fn env_var(&self, name: &str) -> Result<String, String> {
1017 self.calls.lock().unwrap().push(format!("env:{name}"));
1018 Ok("e".into())
1019 }
1020 }
1021
1022 fn all_allowed() -> ScriptAllow {
1023 ScriptAllow {
1024 http_get: true,
1025 http_post: true,
1026 shell: true,
1027 read_file: true,
1028 write_file: true,
1029 env_var: true,
1030 }
1031 }
1032
1033 fn none_allowed() -> ScriptAllow {
1034 ScriptAllow {
1035 http_get: false,
1036 http_post: false,
1037 shell: false,
1038 read_file: false,
1039 write_file: false,
1040 env_var: false,
1041 }
1042 }
1043
1044 #[test]
1050 fn a_script_shell_redirect_answers_to_the_write_permission() {
1051 let io = RecordingIo::arc();
1052 let allow = ScriptAllow {
1053 write_file: false,
1054 ..all_allowed()
1055 };
1056 let host = DaemonScriptHost::with_io(allow, std::env::temp_dir(), io.clone());
1057
1058 let err = host
1059 .shell("echo pwn > /root/.bashrc")
1060 .expect_err("a redirect must answer to the write permission");
1061 assert!(err.contains("write_file"), "got: {err}");
1062
1063 host.shell("echo pwn").expect("a non-writing shell is fine");
1066
1067 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1069 host.shell("echo pwn > x")
1070 .expect("a permitted write is not clamped");
1071 }
1072
1073 #[test]
1078 fn a_script_shell_redirect_stays_inside_the_workdir() {
1079 let dir = tempfile::tempdir().unwrap();
1080 let io = RecordingIo::arc();
1081 let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1082
1083 let err = host
1084 .shell("echo pwn > /root/.bashrc")
1085 .expect_err("an escaping redirect is refused even with writes allowed");
1086 assert!(err.contains("outside the working directory"), "got: {err}");
1087
1088 host.shell("echo ok > inside.txt")
1091 .expect("a redirect inside the workdir runs");
1092 }
1093
1094 #[test]
1095 fn script_write_refuses_a_deleted_workspace() {
1096 let dir = tempfile::tempdir().unwrap();
1099 let workdir = dir.path().join("gone");
1100 let io = RecordingIo::arc();
1101 let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1102 let err = host.write_file("out.txt", "body").unwrap_err();
1103 assert!(err.contains("no longer accessible"), "got: {err}");
1104 assert!(
1105 io.calls.lock().unwrap().is_empty(),
1106 "the io layer never ran"
1107 );
1108 std::fs::create_dir(&workdir).unwrap();
1110 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1111 }
1112
1113 const PUBLIC_URL: &str = "http://93.184.216.34/";
1117
1118 #[test]
1119 fn allowed_calls_delegate_to_io() {
1120 let io = RecordingIo::arc();
1121 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1122 assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1123 assert_eq!(
1124 host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1125 "p"
1126 );
1127 assert_eq!(host.shell("ls").unwrap(), "s");
1128 assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1129 assert_eq!(host.env_var("HOME").unwrap(), "e");
1130 let calls = io.calls.lock().unwrap().clone();
1131 assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1132 assert!(calls.iter().any(|c| c.starts_with("post:")));
1133 assert!(calls.iter().any(|c| c.starts_with("shell:")));
1135 assert!(
1136 calls
1137 .iter()
1138 .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1139 );
1140 assert!(calls.contains(&"env:HOME".to_string()));
1141 }
1142
1143 #[test]
1147 fn outbound_check_blocks_local_targets_before_any_io() {
1148 let io = RecordingIo::arc();
1149 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1150 for url in [
1151 "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1153 "http://127.0.0.1:3000/api/agents",
1155 "http://192.168.1.1/",
1157 "file:///etc/passwd",
1159 ] {
1160 let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1161 assert!(err.starts_with("[denied]"), "{url} → {err}");
1162 let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1163 assert!(err.starts_with("[denied]"), "{url} → {err}");
1164 }
1165 let calls = io.calls.lock().unwrap().clone();
1166 assert!(
1167 calls.is_empty(),
1168 "a refused URL must never reach the I/O backend: {calls:?}"
1169 );
1170 }
1171
1172 #[test]
1177 fn env_var_refuses_credential_names_by_default() {
1178 let io = RecordingIo::arc();
1179 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1180 for name in [
1181 "ANTHROPIC_API_KEY",
1182 "OPENAI_API_KEY",
1183 "AWS_SECRET_ACCESS_KEY",
1184 "GITHUB_TOKEN",
1185 "LEVIATH_API_TOKEN",
1186 ] {
1187 let err = host.env_var(name).unwrap_err();
1188 assert!(err.starts_with("[denied]"), "{name} → {err}");
1189 assert!(err.contains("allow_env_vars"), "{name} → {err}");
1190 }
1191 assert!(
1192 io.calls.lock().unwrap().is_empty(),
1193 "a refused read must never reach the I/O backend"
1194 );
1195 }
1196
1197 #[test]
1200 fn env_var_allows_ordinary_names() {
1201 let io = RecordingIo::arc();
1202 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1203 assert_eq!(host.env_var("PATH").unwrap(), "e");
1204 assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1205 }
1206
1207 #[test]
1210 fn env_var_allowlist_permits_exactly_the_named_variable() {
1211 let io = RecordingIo::arc();
1212 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1213 .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1214 assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1215 assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1216 }
1217
1218 #[test]
1221 fn outbound_check_rejects_unparseable_urls() {
1222 let io = RecordingIo::arc();
1223 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1224 let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1225 assert!(err.contains("invalid URL"), "{err}");
1226 assert!(io.calls.lock().unwrap().is_empty());
1227 }
1228
1229 #[test]
1233 fn allow_local_network_opens_the_local_path() {
1234 let io = RecordingIo::arc();
1235 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1236 .with_local_network(true);
1237 assert_eq!(
1238 host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1239 .unwrap(),
1240 "g"
1241 );
1242 assert!(
1244 host.http_get("file:///etc/passwd", BTreeMap::new())
1245 .is_err()
1246 );
1247 }
1248
1249 static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1254
1255 fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1257 REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1258 }
1259
1260 #[test]
1263 fn redirect_switch_is_independent_of_the_host_field() {
1264 let _guard = lock_redirect_mirror();
1265 let io = RecordingIo::arc();
1266 let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1267 let previous = local_network_allowed();
1268 set_local_network_allowed(true);
1269 let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1270 set_local_network_allowed(previous);
1271 assert!(
1272 decided.is_err(),
1273 "the host field, not the redirect mirror, decides the initial URL"
1274 );
1275 }
1276
1277 #[test]
1278 fn denied_calls_return_denied_and_skip_io() {
1279 let io = RecordingIo::arc();
1280 let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1281 assert!(
1282 host.http_get("http://x", BTreeMap::new())
1283 .unwrap_err()
1284 .contains("[denied]")
1285 );
1286 assert!(
1287 host.http_post("http://x", "b", BTreeMap::new())
1288 .unwrap_err()
1289 .contains("http_post")
1290 );
1291 assert!(host.shell("ls").unwrap_err().contains("shell"));
1292 assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1293 assert!(
1294 host.write_file("a.txt", "b")
1295 .unwrap_err()
1296 .contains("write_file")
1297 );
1298 assert!(host.env_var("X").unwrap_err().contains("env_var"));
1299 assert!(
1300 io.calls.lock().unwrap().is_empty(),
1301 "no I/O on denied calls"
1302 );
1303 }
1304
1305 #[test]
1306 fn read_file_confined_to_workdir() {
1307 let dir = tempfile::tempdir().unwrap();
1308 std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1309 let io = RecordingIo::arc();
1310 let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1311 assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1313 assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1314 let err = host.read_file("../../etc/passwd").unwrap_err();
1317 assert!(err.contains("escape"));
1318 let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1319 assert!(werr.contains("escape"));
1320 let calls = io.calls.lock().unwrap().clone();
1322 assert_eq!(calls.len(), 2);
1323 assert!(calls.iter().any(|c| c.starts_with("read:")));
1324 assert!(calls.iter().any(|c| c.starts_with("write:")));
1325 }
1326
1327 #[test]
1328 fn read_file_absolute_outside_workdir_rejected() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let host =
1331 DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1332 let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1337 assert!(outside.is_absolute(), "test path must be absolute");
1338 let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1339 assert!(err.contains("would escape"), "got: {err}");
1340 }
1341
1342 #[test]
1343 fn read_file_pop_past_root_rejected() {
1344 let host =
1348 DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1349 let err = host.read_file("../..").unwrap_err();
1350 assert!(err.contains("escapes the working directory"), "got: {err}");
1351 }
1352
1353 async fn mock_http() -> String {
1356 use axum::Router;
1357 use axum::routing::{get, post};
1358 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1359 let base = format!("http://{}", listener.local_addr().unwrap());
1360 let app = Router::new()
1361 .route("/ok", get(|| async { "GET-BODY" }))
1362 .route("/echo", post(|body: String| async move { body }))
1363 .route(
1364 "/boom",
1365 get(|| async {
1366 (
1367 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1368 "server error",
1369 )
1370 }),
1371 )
1372 .route(
1375 "/png",
1376 get(|| async {
1377 (
1378 [(axum::http::header::CONTENT_TYPE, "image/png")],
1379 vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1381 )
1382 }),
1383 )
1384 .route(
1387 "/shiftjis",
1388 get(|| async {
1389 (
1390 [(
1391 axum::http::header::CONTENT_TYPE,
1392 "text/html; charset=shift_jis",
1393 )],
1394 vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1396 )
1397 }),
1398 );
1399 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1400 listener, app,
1401 )));
1402 base
1403 }
1404
1405 #[test]
1406 fn binary_content_types_are_classified_but_structured_text_is_not() {
1407 for text in [
1408 "",
1409 "text/html; charset=utf-8",
1410 "text/plain",
1411 "application/json",
1412 "application/xml",
1413 "application/xhtml+xml",
1414 "application/ld+json",
1415 "application/javascript",
1416 ] {
1417 assert!(!is_binary_content_type(text), "should be text: {text:?}");
1418 }
1419 for binary in [
1420 "image/png",
1421 "IMAGE/PNG",
1422 "image/jpeg; charset=binary",
1423 " audio/mpeg ",
1424 "video/mp4",
1425 "font/woff2",
1426 "application/octet-stream",
1427 "application/pdf",
1428 "application/zip",
1429 "application/gzip",
1430 "application/x-tar",
1431 "application/x-bzip2",
1432 "application/wasm",
1433 "application/vnd.ms-excel",
1434 "application/msword",
1435 ] {
1436 assert!(
1437 is_binary_content_type(binary),
1438 "should be binary: {binary:?}"
1439 );
1440 }
1441 }
1442
1443 #[test]
1444 fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1445 let with_len = non_text_body_message("image/png", Some(2049));
1446 assert!(with_len.contains("image/png"), "got: {with_len}");
1447 assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1448 let without_len = non_text_body_message("audio/mpeg", None);
1449 assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1450 assert!(
1451 !without_len.contains("KB"),
1452 "no size to report: {without_len}"
1453 );
1454 }
1455
1456 #[tokio::test(flavor = "multi_thread")]
1457 async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1458 let base = mock_http().await;
1459 let (png, sjis) = tokio::task::spawn_blocking(move || {
1460 (
1461 RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1462 RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1463 )
1464 })
1465 .await
1466 .unwrap();
1467
1468 let err = png.unwrap_err();
1470 assert!(err.contains("non-text content"), "got: {err}");
1471 assert!(err.contains("image/png"), "got: {err}");
1472
1473 assert_eq!(sjis.unwrap(), "日本語");
1476 }
1477
1478 #[test]
1482 fn oversized_declared_body_is_refused() {
1483 let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1484 assert!(msg.contains("999999999"), "{msg}");
1485 assert!(msg.contains("1000-byte limit"), "{msg}");
1486 }
1487
1488 #[test]
1492 fn body_within_cap_or_of_unknown_size_proceeds() {
1493 assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1494 assert!(oversized_body_message(Some(0), 1_000).is_none());
1495 assert!(oversized_body_message(None, 1_000).is_none());
1496 }
1497
1498 #[tokio::test(flavor = "multi_thread")]
1502 async fn send_refuses_a_body_over_the_cap() {
1503 let base = mock_http().await;
1504 let out = tokio::task::spawn_blocking(move || {
1505 let client = RealScriptIo::client();
1506 RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1508 })
1509 .await
1510 .unwrap();
1511 let err = out.expect_err("a body over the cap is refused");
1512 assert!(err.contains("over the"), "got: {err}");
1513 }
1514
1515 #[tokio::test(flavor = "multi_thread")]
1520 async fn redirects_to_a_local_address_are_refused() {
1521 use axum::Router;
1522 use axum::response::Redirect;
1523 use axum::routing::get;
1524
1525 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1526 let addr = listener.local_addr().unwrap();
1527 let app = Router::new().route(
1531 "/bounce",
1532 get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1533 );
1534 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1535 listener, app,
1536 )));
1537
1538 let out = tokio::task::spawn_blocking(move || {
1542 let _guard = lock_redirect_mirror();
1543 let previous = local_network_allowed();
1544 set_local_network_allowed(false);
1545 let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1546 set_local_network_allowed(previous);
1547 result
1548 })
1549 .await
1550 .unwrap();
1551 let err = out.expect_err("a redirect to loopback must not be followed");
1552 assert!(err.contains("refused to follow redirect"), "got: {err}");
1553 }
1554
1555 #[tokio::test(flavor = "multi_thread")]
1558 async fn a_redirect_loop_is_bounded() {
1559 use axum::Router;
1560 use axum::response::Redirect;
1561 use axum::routing::get;
1562
1563 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1564 let addr = listener.local_addr().unwrap();
1565 let app = Router::new().route(
1566 "/loop",
1567 get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1568 );
1569 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1570 listener, app,
1571 )));
1572
1573 let out = tokio::task::spawn_blocking(move || {
1574 let _guard = lock_redirect_mirror();
1575 let previous = local_network_allowed();
1577 set_local_network_allowed(true);
1578 let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1579 set_local_network_allowed(previous);
1580 result
1581 })
1582 .await
1583 .unwrap();
1584 let err = out.expect_err("an endless redirect must be stopped");
1585 assert!(err.contains("too many redirects"), "got: {err}");
1586 }
1587
1588 #[test]
1593 fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1594 fn escapes(_: &Path, _: &Path) -> bool {
1595 false
1596 }
1597 let dir = tempfile::tempdir().unwrap();
1598 let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1599 .expect_err("a path that resolves outside must be refused");
1600 assert!(err.contains("symlink"), "{err}");
1601 }
1602
1603 #[test]
1607 fn resolve_in_admits_the_null_device() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let resolved =
1610 DaemonScriptHost::resolve_in("/dev/null", dir.path(), leviath_core::resolves_within)
1611 .expect("the null device is not an escape");
1612 assert_eq!(resolved, PathBuf::from("/dev/null"));
1613 }
1614
1615 #[test]
1618 fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1619 let dir = tempfile::tempdir().unwrap();
1620 let resolved =
1621 DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1622 .expect("an ordinary path resolves");
1623 assert!(resolved.ends_with("notes.txt"));
1624 }
1625
1626 #[cfg(unix)]
1629 #[test]
1630 fn script_host_read_refuses_a_symlink_escape() {
1631 let dir = tempfile::tempdir().unwrap();
1632 let workdir = dir.path().join("workspace");
1633 std::fs::create_dir(&workdir).unwrap();
1634 std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1635
1636 let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1637 let err = host.read_file("link/etc/hosts").unwrap_err();
1638 assert!(err.contains("symlink"), "got: {err}");
1639 }
1640
1641 #[tokio::test(flavor = "multi_thread")]
1642 async fn real_http_get_success_and_headers() {
1643 let base = mock_http().await;
1644 let out = tokio::task::spawn_blocking(move || {
1645 let mut h = BTreeMap::new();
1646 h.insert("X-Test".to_string(), "1".to_string());
1647 RealScriptIo.http_get(&format!("{base}/ok"), h)
1648 })
1649 .await
1650 .unwrap();
1651 assert_eq!(out.unwrap(), "GET-BODY");
1652 }
1653
1654 #[tokio::test(flavor = "multi_thread")]
1655 async fn real_http_get_non_success_is_error() {
1656 let base = mock_http().await;
1657 let out = tokio::task::spawn_blocking(move || {
1658 RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1659 })
1660 .await
1661 .unwrap();
1662 let err = out.unwrap_err();
1663 assert!(
1664 err.contains("http 500") && err.contains("server error"),
1665 "got: {err}"
1666 );
1667 }
1668
1669 #[tokio::test(flavor = "multi_thread")]
1670 async fn real_http_get_connection_error() {
1671 let out = tokio::task::spawn_blocking(|| {
1673 RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1674 })
1675 .await
1676 .unwrap();
1677 assert!(out.unwrap_err().contains("request failed"));
1678 }
1679
1680 async fn spawn_truncated_body_server() -> String {
1684 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1685 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1686 let addr = listener.local_addr().unwrap();
1687 let body = b"partial";
1688 let response = format!(
1689 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1690 body.len() + 4096
1691 )
1692 .into_bytes();
1693 tokio::spawn(async move {
1694 let (mut socket, _) = listener.accept().await.unwrap();
1695 let mut buf = [0u8; 8192];
1696 let _ = socket.read(&mut buf).await;
1697 let _ = socket.write_all(&response).await;
1698 let _ = socket.write_all(body).await;
1699 let _ = socket.flush().await;
1700 let _ = socket.shutdown().await;
1701 });
1702 format!("http://{addr}")
1703 }
1704
1705 #[tokio::test(flavor = "multi_thread")]
1706 async fn real_http_body_read_error() {
1707 let base = spawn_truncated_body_server().await;
1708 let out = tokio::task::spawn_blocking(move || {
1709 RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1710 })
1711 .await
1712 .unwrap();
1713 let err = out.unwrap_err();
1714 assert!(err.contains("read body"), "got: {err}");
1715 }
1716
1717 #[tokio::test(flavor = "multi_thread")]
1718 async fn real_http_post_echoes_body() {
1719 let base = mock_http().await;
1720 let out = tokio::task::spawn_blocking(move || {
1721 RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1722 })
1723 .await
1724 .unwrap();
1725 assert_eq!(out.unwrap(), "hello");
1726 }
1727
1728 async fn run_host_shell(
1731 command: &'static str,
1732 workdir: PathBuf,
1733 timeout: Duration,
1734 ) -> Result<String, String> {
1735 tokio::task::spawn_blocking(move || {
1736 let (shell, flag) = default_shell();
1737 let cmd = host_shell_command(shell, flag, command, &workdir);
1738 RealScriptIo.run_shell(cmd, timeout)
1739 })
1740 .await
1741 .unwrap()
1742 }
1743
1744 #[test]
1745 fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1746 let dir = tempfile::tempdir().unwrap();
1751 let workdir = dir.path().to_path_buf();
1752 let err = std::thread::spawn(move || {
1753 let (shell, flag) = default_shell();
1754 let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1755 RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1756 })
1757 .join()
1758 .unwrap()
1759 .unwrap_err();
1760 assert!(err.contains("no tokio runtime"), "got: {err}");
1761 }
1762
1763 #[tokio::test(flavor = "multi_thread")]
1764 async fn real_shell_runs_and_captures_output() {
1765 let dir = tempfile::tempdir().unwrap();
1766 let out = run_host_shell(
1768 "echo hello",
1769 dir.path().to_path_buf(),
1770 Duration::from_secs(30),
1771 )
1772 .await
1773 .unwrap();
1774 assert!(out.contains("hello"));
1775 let out2 = run_host_shell(
1777 "echo oops 1>&2",
1778 dir.path().to_path_buf(),
1779 Duration::from_secs(30),
1780 )
1781 .await
1782 .unwrap();
1783 assert!(out2.contains("oops"));
1784 }
1785
1786 #[tokio::test(flavor = "multi_thread")]
1787 async fn real_shell_spawn_failure() {
1788 let missing = PathBuf::from("/no/such/workdir/leviath");
1790 let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1791 .await
1792 .unwrap_err();
1793 assert!(err.contains("failed to spawn shell"), "got: {err}");
1794 }
1795
1796 #[tokio::test(flavor = "multi_thread")]
1797 async fn real_shell_times_out() {
1798 let dir = tempfile::tempdir().unwrap();
1800 let err = run_host_shell(
1801 "sleep 5",
1802 dir.path().to_path_buf(),
1803 Duration::from_millis(50),
1804 )
1805 .await
1806 .unwrap_err();
1807 assert!(err.contains("timed out"), "got: {err}");
1808 }
1809
1810 #[test]
1811 fn combine_shell_output_appends_nonempty_stderr_only() {
1812 assert_eq!(combine_shell_output(b"out", b" "), "out");
1814 assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1815 }
1816
1817 #[test]
1818 fn host_shell_command_targets_workdir() {
1819 let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1820 assert_eq!(cmd.as_std().get_program(), "sh");
1821 }
1822
1823 #[test]
1824 fn shell_routes_through_sandbox_when_present() {
1825 use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1826 let by_index = vec![ToolSandboxConfig {
1830 kind: SandboxKind::Namespace,
1831 on_unavailable: OnUnavailable::Warn,
1832 ..Default::default()
1833 }];
1834 let sb = SandboxManager::build("r", by_index, "/w", 0)
1835 .unwrap()
1836 .map(Arc::new);
1837 assert!(sb.is_some(), "namespace warn config yields a manager");
1838 let io = RecordingIo::arc();
1839 let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1840 .with_shell(sb, Duration::from_secs(5), Default::default());
1841 assert_eq!(host.shell("ls").unwrap(), "s");
1842 assert!(
1843 io.calls
1844 .lock()
1845 .unwrap()
1846 .iter()
1847 .any(|c| c.starts_with("shell:"))
1848 );
1849 }
1850
1851 #[test]
1852 fn real_read_file_success_and_error() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let p = dir.path().join("f.txt");
1855 std::fs::write(&p, "data").unwrap();
1856 assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1857 let err = RealScriptIo
1858 .read_file(&dir.path().join("nope"))
1859 .unwrap_err();
1860 assert!(err.contains("read '"));
1861 }
1862
1863 #[test]
1864 fn real_write_file_creates_parents_and_reports() {
1865 let dir = tempfile::tempdir().unwrap();
1866 let nested = dir.path().join("sub/deep/out.txt");
1868 let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1869 assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1870 assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1871 }
1872
1873 #[test]
1874 fn real_write_file_create_dir_error() {
1875 let dir = tempfile::tempdir().unwrap();
1876 let blocker = dir.path().join("afile");
1878 std::fs::write(&blocker, "x").unwrap();
1879 let err = RealScriptIo
1880 .write_file(&blocker.join("child.txt"), "b")
1881 .unwrap_err();
1882 assert!(err.contains("create dir"), "got: {err}");
1883 }
1884
1885 #[test]
1886 fn real_write_file_write_error() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1890 assert!(err.contains("write '"), "got: {err}");
1891 }
1892
1893 #[test]
1894 fn real_write_file_parentless_path() {
1895 let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1898 assert!(err.contains("write '"), "got: {err}");
1899 }
1900
1901 #[test]
1902 fn real_env_var_set_and_unset() {
1903 temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1904 assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1905 });
1906 temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1907 assert!(
1908 RealScriptIo
1909 .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1910 .unwrap_err()
1911 .contains("not set")
1912 );
1913 });
1914 }
1915
1916 #[test]
1917 fn default_shell_is_platform_appropriate() {
1918 let (shell, flag) = default_shell();
1919 assert!(!shell.is_empty());
1920 assert!(!flag.is_empty());
1921 }
1922
1923 #[test]
1928 fn default_shell_for_answers_per_platform() {
1929 assert_eq!(default_shell_for("windows"), ("cmd.exe", "/C"));
1930 for posix in ["linux", "macos", "freebsd", "haiku"] {
1931 assert_eq!(default_shell_for(posix), ("/bin/sh", "-c"), "{posix}");
1932 }
1933 }
1934
1935 #[test]
1936 fn new_wires_real_io() {
1937 let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1939 temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1941 assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1942 });
1943 }
1944
1945 #[test]
1946 fn cap_script_io_leaves_small_strings_untouched() {
1947 let s = "small".to_string();
1948 assert_eq!(cap_script_io(s.clone()), s);
1949 }
1950
1951 #[test]
1952 fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1953 let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1954 let capped = cap_script_io(big);
1955 assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1956 assert!(capped.contains("[...truncated by leviath"));
1957 }
1958
1959 #[test]
1960 fn cap_script_io_truncates_on_a_char_boundary() {
1961 let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1963 s.push('é'); s.push_str(&"b".repeat(10));
1965 let capped = cap_script_io(s);
1966 assert!(capped.contains("[...truncated by leviath"));
1968 }
1969}