1use std::path::Path;
2
3use tokio::process::Command;
4
5use roboticus_core::config::{FilesystemSecurityConfig, SkillsConfig};
6use roboticus_core::{Result, RoboticusError};
7
8#[derive(Debug, Clone)]
9pub struct ScriptResult {
10 pub stdout: String,
11 pub stderr: String,
12 pub exit_code: i32,
13 pub duration_ms: u64,
14}
15
16pub struct ScriptRunner {
17 config: SkillsConfig,
18 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
20 fs_security: FilesystemSecurityConfig,
21}
22
23impl ScriptRunner {
24 pub fn new(config: SkillsConfig, fs_security: FilesystemSecurityConfig) -> Self {
25 Self {
26 config,
27 fs_security,
28 }
29 }
30
31 pub async fn execute(&self, script_path: &Path, args: &[&str]) -> Result<ScriptResult> {
32 let script_path = self.resolve_script_path(script_path)?;
33 let interpreter = check_interpreter(&script_path, &self.config.allowed_interpreters)?;
34
35 let working_dir = script_path.parent().unwrap_or(Path::new("."));
36
37 #[cfg(target_os = "macos")]
41 let _sandbox_profile: Option<tempfile::NamedTempFile>;
42
43 let mut cmd;
44
45 #[cfg(target_os = "macos")]
46 {
47 if self.fs_security.script_fs_confinement && self.config.sandbox_env {
48 let profile = generate_sandbox_profile(
49 &self.config.skills_dir,
50 self.config.workspace_dir.as_deref(),
51 &self.fs_security.script_allowed_paths,
52 self.config.network_allowed,
53 )?;
54 let profile_path = profile.path().to_path_buf();
55 _sandbox_profile = Some(profile);
56
57 cmd = Command::new("/usr/bin/sandbox-exec");
58 cmd.arg("-f")
59 .arg(profile_path)
60 .arg(&interpreter)
61 .arg(&script_path)
62 .args(args)
63 .current_dir(working_dir);
64 } else {
65 if self.fs_security.sandbox_required && self.fs_security.script_fs_confinement {
67 return Err(RoboticusError::Tool {
68 tool: "script_runner".into(),
69 message: "sandbox_required is true but macOS sandbox-exec confinement \
70 could not be applied (sandbox_env disabled?)"
71 .into(),
72 });
73 }
74 _sandbox_profile = None;
75 cmd = Command::new(&interpreter);
76 cmd.arg(&script_path).args(args).current_dir(working_dir);
77 }
78 }
79
80 #[cfg(not(target_os = "macos"))]
81 {
82 cmd = Command::new(&interpreter);
83 cmd.arg(&script_path).args(args).current_dir(working_dir);
84 }
85
86 if self.config.sandbox_env {
87 cmd.env_clear();
88 if let Ok(path) = std::env::var("PATH") {
89 cmd.env("PATH", path);
90 }
91 if let Some(home) = default_home_env() {
92 cmd.env("HOME", home);
93 }
94 for key in ["USERPROFILE", "TMPDIR", "TMP", "TEMP", "LANG", "TERM"] {
95 if let Ok(val) = std::env::var(key) {
96 cmd.env(key, val);
97 }
98 }
99 cmd.env("ROBOTICUS_SKILLS_DIR", &self.config.skills_dir);
102 if let Some(ref ws) = self.config.workspace_dir {
103 cmd.env("ROBOTICUS_WORKSPACE", ws);
104 }
105 }
106
107 #[cfg(unix)]
109 {
110 let mem_limit = self.config.script_max_memory_bytes;
111 let deny_net = self.config.sandbox_env && !self.config.network_allowed;
112 let fs_confine = self.fs_security.script_fs_confinement;
113 let sandbox_required = self.fs_security.sandbox_required;
114 let workspace_dir = self.config.workspace_dir.clone();
115 let allowed_paths = self.fs_security.script_allowed_paths.clone();
116 unsafe {
119 cmd.pre_exec(move || {
120 #[cfg(target_os = "linux")]
125 if let Some(max_bytes) = mem_limit {
126 let rlim = libc::rlimit {
127 rlim_cur: max_bytes,
128 rlim_max: max_bytes,
129 };
130 if libc::setrlimit(libc::RLIMIT_AS, &rlim) != 0 {
131 return Err(std::io::Error::last_os_error());
132 }
133 }
134 #[cfg(not(target_os = "linux"))]
135 let _ = mem_limit;
136 #[cfg(target_os = "linux")]
138 if deny_net && libc::unshare(libc::CLONE_NEWNET) != 0 {
139 eprintln!(
142 "roboticus: warning: network isolation unavailable (unshare failed)"
143 );
144 }
145 #[cfg(not(target_os = "linux"))]
148 let _ = deny_net;
149
150 #[cfg(target_os = "linux")]
154 if fs_confine {
155 let applied =
156 apply_landlock_confinement(workspace_dir.as_deref(), &allowed_paths);
157 if !applied && sandbox_required {
158 return Err(std::io::Error::other(
159 "sandbox_required is true but Landlock confinement could not be applied",
160 ));
161 }
162 }
163 #[cfg(not(target_os = "linux"))]
164 {
165 let _ = (fs_confine, sandbox_required, &workspace_dir, &allowed_paths);
166 }
167
168 Ok(())
169 });
170 }
171 }
172
173 cmd.stdout(std::process::Stdio::piped());
174 cmd.stderr(std::process::Stdio::piped());
175
176 let timeout_dur = std::time::Duration::from_secs(self.config.script_timeout_seconds);
177 let start = std::time::Instant::now();
178 let max = self.config.script_max_output_bytes;
179 let max_capture = (max as u64).saturating_add(1);
180
181 let mut child = cmd.spawn().map_err(|e| RoboticusError::Tool {
182 tool: "script_runner".into(),
183 message: format!("failed to spawn {interpreter}: {e}"),
184 })?;
185
186 #[cfg(target_os = "windows")]
191 let _job_guard: Option<crate::sandbox_windows::JobGuard>;
192
193 #[cfg(target_os = "windows")]
194 {
195 if self.fs_security.script_fs_confinement && self.config.sandbox_env {
196 let handle = child
199 .id()
200 .map(|pid| crate::sandbox_windows::open_process_handle(pid));
201 let handle = match handle {
202 Some(Ok(h)) => h,
203 Some(Err(e)) => {
204 tracing::warn!(error = %e, "failed to open process handle for sandboxing");
205 std::ptr::null_mut()
206 }
207 None => std::ptr::null_mut(),
208 };
209 match crate::sandbox_windows::apply_job_confinement(
210 handle,
211 self.config.script_max_memory_bytes,
212 ) {
213 Ok(guard) => {
214 _job_guard = guard;
215 crate::sandbox_windows::warn_fs_confinement_limited();
216 }
217 Err(e) => {
218 tracing::warn!(
219 error = %e,
220 "Windows sandbox confinement failed (graceful degradation)"
221 );
222 _job_guard = None;
223 }
224 }
225 } else {
226 _job_guard = None;
227 }
228 }
229 let stdout = child.stdout.take().ok_or_else(|| RoboticusError::Tool {
230 tool: "script_runner".into(),
231 message: "failed to capture script stdout".into(),
232 })?;
233 let stderr = child.stderr.take().ok_or_else(|| RoboticusError::Tool {
234 tool: "script_runner".into(),
235 message: "failed to capture script stderr".into(),
236 })?;
237 let stdout_task = tokio::spawn(async move {
238 use tokio::io::AsyncReadExt;
239 let mut buf = Vec::new();
240 let _ = stdout.take(max_capture).read_to_end(&mut buf).await;
241 buf
242 });
243 let stderr_task = tokio::spawn(async move {
244 use tokio::io::AsyncReadExt;
245 let mut buf = Vec::new();
246 let _ = stderr.take(max_capture).read_to_end(&mut buf).await;
247 buf
248 });
249
250 let status = match tokio::time::timeout(timeout_dur, child.wait()).await {
251 Ok(Ok(status)) => status,
252 Ok(Err(e)) => {
253 return Err(RoboticusError::Tool {
254 tool: "script_runner".into(),
255 message: format!("process error: {e}"),
256 });
257 }
258 Err(_) => {
259 let _ = child.kill().await;
260 let _ = child.wait().await;
261 return Err(RoboticusError::Tool {
262 tool: "script_runner".into(),
263 message: format!(
264 "script timed out after {}s",
265 self.config.script_timeout_seconds
266 ),
267 });
268 }
269 };
270
271 let duration_ms = start.elapsed().as_millis() as u64;
272 let stdout_bytes = stdout_task.await.unwrap_or_default();
273 let stderr_bytes = stderr_task.await.unwrap_or_default();
274 let stdout_raw = String::from_utf8_lossy(&stdout_bytes);
275 let stderr_raw = String::from_utf8_lossy(&stderr_bytes);
276
277 let stdout = truncate_str(&stdout_raw, max);
278 let stderr = truncate_str(&stderr_raw, max);
279
280 Ok(ScriptResult {
281 stdout,
282 stderr,
283 exit_code: status.code().unwrap_or(-1),
284 duration_ms,
285 })
286 }
287
288 pub fn resolve_script_path(&self, requested: &Path) -> Result<std::path::PathBuf> {
292 if requested.is_absolute() {
293 return Err(RoboticusError::Config(
294 "absolute script paths are not allowed".into(),
295 ));
296 }
297
298 let root =
299 std::fs::canonicalize(&self.config.skills_dir).map_err(|e| RoboticusError::Tool {
300 tool: "script_runner".into(),
301 message: format!(
302 "failed to resolve skills_dir '{}': {e}",
303 self.config.skills_dir.display()
304 ),
305 })?;
306 let joined = root.join(requested);
307 let canonical = std::fs::canonicalize(&joined).map_err(|e| RoboticusError::Tool {
308 tool: "script_runner".into(),
309 message: format!("failed to resolve script path '{}': {e}", joined.display()),
310 })?;
311 if !canonical.starts_with(&root) {
312 return Err(RoboticusError::Tool {
313 tool: "script_runner".into(),
314 message: format!(
315 "script path '{}' escapes skills_dir '{}'",
316 canonical.display(),
317 root.display()
318 ),
319 });
320 }
321 if !canonical.is_file() {
322 return Err(RoboticusError::Tool {
323 tool: "script_runner".into(),
324 message: format!("script path '{}' is not a file", canonical.display()),
325 });
326 }
327
328 #[cfg(unix)]
329 {
330 use std::os::unix::fs::PermissionsExt;
331 let metadata = std::fs::metadata(&canonical).map_err(|e| RoboticusError::Tool {
332 tool: "script_runner".into(),
333 message: format!("failed to read metadata for '{}': {e}", canonical.display()),
334 })?;
335 let mode = metadata.permissions().mode();
336 if mode & 0o002 != 0 {
337 return Err(RoboticusError::Tool {
338 tool: "script_runner".into(),
339 message: format!(
340 "script '{}' is world-writable (mode {:o})",
341 canonical.display(),
342 mode
343 ),
344 });
345 }
346 }
347
348 Ok(canonical)
349 }
350}
351
352#[cfg(target_os = "macos")]
364fn generate_sandbox_profile(
365 _skills_dir: &Path,
366 workspace_dir: Option<&Path>,
367 extra_paths: &[std::path::PathBuf],
368 network_allowed: bool,
369) -> Result<tempfile::NamedTempFile> {
370 use std::io::Write;
371
372 let canon = |p: &Path| -> String {
376 p.canonicalize()
377 .unwrap_or_else(|_| p.to_path_buf())
378 .display()
379 .to_string()
380 };
381
382 let mut profile = tempfile::NamedTempFile::new().map_err(|e| RoboticusError::Tool {
383 tool: "script_runner".into(),
384 message: format!("failed to create sandbox profile tempfile: {e}"),
385 })?;
386
387 let mut sb = String::with_capacity(2048);
397 sb.push_str("(version 1)\n");
398 sb.push_str("(deny default)\n\n");
399
400 sb.push_str("; Process execution for interpreters\n");
402 sb.push_str("(allow process-exec)\n");
403 sb.push_str("(allow process-fork)\n\n");
404
405 sb.push_str("; Global read access — writes are the confinement boundary\n");
409 sb.push_str("(allow file-read*)\n\n");
410
411 sb.push_str("; /dev/null, /dev/zero — scripts redirect stderr here\n");
414 sb.push_str("(allow file-write* (literal \"/dev/null\") (literal \"/dev/zero\"))\n\n");
415
416 sb.push_str("; Scratch space — /tmp and /private/tmp\n");
417 sb.push_str("(allow file-write* (subpath \"/tmp\"))\n");
418 sb.push_str("(allow file-write* (subpath \"/private/tmp\"))\n\n");
419
420 if let Some(ws) = workspace_dir {
422 sb.push_str("; Workspace directory — writable\n");
423 sb.push_str(&format!(
424 "(allow file-write* (subpath \"{}\"))\n\n",
425 canon(ws)
426 ));
427 }
428
429 for p in extra_paths {
431 sb.push_str(&format!("(allow file-write* (subpath \"{}\"))\n", canon(p)));
432 }
433 if !extra_paths.is_empty() {
434 sb.push('\n');
435 }
436
437 sb.push_str("; IPC and signals for language runtimes\n");
440 sb.push_str("(allow sysctl-read)\n");
441 sb.push_str("(allow mach-lookup)\n");
442 sb.push_str("(allow signal (target self))\n");
443 sb.push_str("(allow ipc-posix-shm-read-data)\n");
444 sb.push_str("(allow ipc-posix-shm-write-data)\n\n");
445
446 if network_allowed {
450 sb.push_str("; Network access allowed by configuration\n");
451 sb.push_str("(allow network*)\n");
452 } else {
453 sb.push_str("; Network denied (sandbox_env + !network_allowed)\n");
454 }
455
456 profile
457 .write_all(sb.as_bytes())
458 .map_err(|e| RoboticusError::Tool {
459 tool: "script_runner".into(),
460 message: format!("failed to write sandbox profile: {e}"),
461 })?;
462
463 Ok(profile)
464}
465
466fn truncate_str(s: &str, max_bytes: usize) -> String {
467 if s.len() <= max_bytes {
468 s.to_string()
469 } else {
470 let mut end = max_bytes;
471 while end > 0 && !s.is_char_boundary(end) {
472 end -= 1;
473 }
474 s[..end].to_string()
475 }
476}
477
478fn default_home_env() -> Option<String> {
479 std::env::var("HOME")
480 .ok()
481 .or_else(|| std::env::var("USERPROFILE").ok())
482}
483
484fn default_python_interpreter() -> &'static str {
485 #[cfg(windows)]
486 {
487 "python"
488 }
489 #[cfg(not(windows))]
490 {
491 "python3"
492 }
493}
494
495pub fn resolve_interpreter_absolute(name: &str) -> Result<String> {
501 let p = Path::new(name);
502 if p.is_absolute() {
503 let canonical = std::fs::canonicalize(p).map_err(|e| RoboticusError::Tool {
504 tool: "script_runner".into(),
505 message: format!("interpreter '{name}' not found: {e}"),
506 })?;
507 return Ok(canonical.to_string_lossy().to_string());
508 }
509 let path_var = std::env::var("PATH").unwrap_or_default();
510 for dir in std::env::split_paths(&path_var) {
511 let candidate = dir.join(name);
512 if candidate.is_file()
513 && let Ok(canonical) = std::fs::canonicalize(&candidate)
514 {
515 return Ok(canonical.to_string_lossy().to_string());
516 }
517 }
518 Err(RoboticusError::Tool {
519 tool: "script_runner".into(),
520 message: format!("interpreter '{name}' not found in PATH"),
521 })
522}
523
524pub fn check_interpreter(script_path: &Path, allowed: &[String]) -> Result<String> {
528 if let Ok(first_line) = std::fs::File::open(script_path).and_then(|f| {
529 use std::io::{BufRead, Read};
530 let mut line = String::new();
531 std::io::BufReader::new(f.take(512)).read_line(&mut line)?;
532 Ok(line)
533 }) && first_line.starts_with("#!")
534 {
535 let shebang = first_line[2..].trim();
536 let interpreter = shebang
537 .split('/')
538 .next_back()
539 .unwrap_or(shebang)
540 .split_whitespace()
541 .next()
542 .unwrap_or(shebang);
543
544 let interp = if interpreter == "env" {
545 shebang.split_whitespace().nth(1).unwrap_or(interpreter)
546 } else {
547 interpreter
548 };
549
550 if allowed.iter().any(|a| a == interp) {
551 return resolve_interpreter_absolute(interp);
552 } else {
553 return Err(RoboticusError::Tool {
554 tool: "script_runner".into(),
555 message: format!("interpreter '{interp}' not in whitelist: {allowed:?}"),
556 });
557 }
558 }
559
560 let ext = script_path
561 .extension()
562 .and_then(|e| e.to_str())
563 .unwrap_or("");
564
565 let inferred = match ext {
566 "py" => default_python_interpreter(),
567 "sh" | "bash" => "bash",
568 "js" => "node",
569 _ => {
570 return Err(RoboticusError::Tool {
571 tool: "script_runner".into(),
572 message: format!("cannot infer interpreter for extension '.{ext}'"),
573 });
574 }
575 };
576
577 if allowed.iter().any(|a| a == inferred) {
578 resolve_interpreter_absolute(inferred)
579 } else {
580 Err(RoboticusError::Tool {
581 tool: "script_runner".into(),
582 message: format!("interpreter '{inferred}' not in whitelist: {allowed:?}"),
583 })
584 }
585}
586
587#[cfg(target_os = "linux")]
598fn apply_landlock_confinement(
599 workspace_dir: Option<&std::path::Path>,
600 allowed_paths: &[std::path::PathBuf],
601) -> bool {
602 const LANDLOCK_CREATE_RULESET: libc::c_long = 444;
604 const LANDLOCK_ADD_RULE: libc::c_long = 445;
605 const LANDLOCK_RESTRICT_SELF: libc::c_long = 446;
606 const LANDLOCK_RULE_PATH_BENEATH: libc::c_uint = 1;
607
608 const LANDLOCK_ACCESS_FS_EXECUTE: u64 = 1 << 0;
610 const LANDLOCK_ACCESS_FS_WRITE_FILE: u64 = 1 << 1;
611 const LANDLOCK_ACCESS_FS_READ_FILE: u64 = 1 << 2;
612 const LANDLOCK_ACCESS_FS_READ_DIR: u64 = 1 << 3;
613 const LANDLOCK_ACCESS_FS_REMOVE_DIR: u64 = 1 << 4;
614 const LANDLOCK_ACCESS_FS_REMOVE_FILE: u64 = 1 << 5;
615 const LANDLOCK_ACCESS_FS_MAKE_CHAR: u64 = 1 << 6;
616 const LANDLOCK_ACCESS_FS_MAKE_DIR: u64 = 1 << 7;
617 const LANDLOCK_ACCESS_FS_MAKE_REG: u64 = 1 << 8;
618 const LANDLOCK_ACCESS_FS_MAKE_SOCK: u64 = 1 << 9;
619 const LANDLOCK_ACCESS_FS_MAKE_FIFO: u64 = 1 << 10;
620 const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u64 = 1 << 11;
621 const LANDLOCK_ACCESS_FS_MAKE_SYM: u64 = 1 << 12;
622
623 const ALL_WRITE: u64 = LANDLOCK_ACCESS_FS_WRITE_FILE
624 | LANDLOCK_ACCESS_FS_REMOVE_DIR
625 | LANDLOCK_ACCESS_FS_REMOVE_FILE
626 | LANDLOCK_ACCESS_FS_MAKE_CHAR
627 | LANDLOCK_ACCESS_FS_MAKE_DIR
628 | LANDLOCK_ACCESS_FS_MAKE_REG
629 | LANDLOCK_ACCESS_FS_MAKE_SOCK
630 | LANDLOCK_ACCESS_FS_MAKE_FIFO
631 | LANDLOCK_ACCESS_FS_MAKE_BLOCK
632 | LANDLOCK_ACCESS_FS_MAKE_SYM;
633
634 const ALL_ACCESS: u64 = ALL_WRITE
635 | LANDLOCK_ACCESS_FS_EXECUTE
636 | LANDLOCK_ACCESS_FS_READ_FILE
637 | LANDLOCK_ACCESS_FS_READ_DIR;
638
639 #[repr(C)]
640 struct LandlockRulesetAttr {
641 handled_access_fs: u64,
642 handled_access_net: u64,
643 }
644
645 #[repr(C)]
646 struct LandlockPathBeneathAttr {
647 allowed_access: u64,
648 parent_fd: libc::c_int,
649 }
650
651 unsafe {
653 if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
654 eprintln!("roboticus: warning: Landlock unavailable (PR_SET_NO_NEW_PRIVS failed)");
655 return false;
656 }
657 }
658
659 let attr = LandlockRulesetAttr {
661 handled_access_fs: ALL_ACCESS,
662 handled_access_net: 0,
663 };
664 let ruleset_fd = unsafe {
665 libc::syscall(
666 LANDLOCK_CREATE_RULESET,
667 &attr as *const _ as *const libc::c_void,
668 std::mem::size_of::<LandlockRulesetAttr>(),
669 0u32,
670 )
671 };
672 if ruleset_fd < 0 {
673 eprintln!(
674 "roboticus: warning: Landlock unavailable (create_ruleset failed — kernel < 5.13?)"
675 );
676 return false;
677 }
678 let ruleset_fd = ruleset_fd as libc::c_int;
679
680 let add_path_rule = |path: &std::path::Path, access: u64| {
682 let fd = unsafe {
683 libc::open(
684 path.as_os_str().as_encoded_bytes().as_ptr() as *const libc::c_char,
685 libc::O_PATH | libc::O_CLOEXEC,
686 )
687 };
688 if fd < 0 {
689 return; }
691 let rule = LandlockPathBeneathAttr {
692 allowed_access: access,
693 parent_fd: fd,
694 };
695 unsafe {
696 libc::syscall(
697 LANDLOCK_ADD_RULE,
698 ruleset_fd,
699 LANDLOCK_RULE_PATH_BENEATH,
700 &rule as *const _ as *const libc::c_void,
701 0u32,
702 );
703 libc::close(fd);
704 }
705 };
706
707 add_path_rule(std::path::Path::new("/"), ALL_ACCESS & !ALL_WRITE);
710
711 add_path_rule(std::path::Path::new("/tmp"), ALL_ACCESS);
713
714 if let Some(ws) = workspace_dir {
716 add_path_rule(ws, ALL_ACCESS);
717 }
718
719 for path in allowed_paths {
721 add_path_rule(path, ALL_ACCESS);
722 }
723
724 let ret = unsafe { libc::syscall(LANDLOCK_RESTRICT_SELF, ruleset_fd, 0u32) };
726 unsafe { libc::close(ruleset_fd) };
727
728 if ret < 0 {
729 eprintln!("roboticus: warning: Landlock enforcement failed (restrict_self)");
730 return false;
731 }
732
733 true
734}
735
736#[cfg(test)]
737#[cfg(unix)]
738mod tests {
739 use super::*;
740 use crate::test_support::EnvGuard;
741 use std::fs;
742 use std::os::unix::fs::PermissionsExt;
743
744 fn test_config() -> SkillsConfig {
745 SkillsConfig {
746 script_timeout_seconds: 5,
747 script_max_output_bytes: 1024,
748 allowed_interpreters: vec!["bash".into(), "python3".into(), "node".into()],
749 sandbox_env: true,
750 ..Default::default()
751 }
752 }
753
754 fn test_fs_security() -> FilesystemSecurityConfig {
755 FilesystemSecurityConfig {
756 script_fs_confinement: false,
759 ..Default::default()
760 }
761 }
762
763 #[tokio::test]
764 async fn successful_script_execution() {
765 let dir = tempfile::tempdir().unwrap();
766 let script = dir.path().join("test.sh");
767 fs::write(&script, "#!/bin/bash\necho \"hello from script\"").unwrap();
768 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
769
770 let mut cfg = test_config();
771 cfg.skills_dir = dir.path().to_path_buf();
772 let runner = ScriptRunner::new(cfg, test_fs_security());
773 let result = runner.execute(Path::new("test.sh"), &[]).await.unwrap();
774
775 assert_eq!(result.exit_code, 0);
776 assert!(result.stdout.contains("hello from script"));
777 }
778
779 #[test]
780 fn interpreter_whitelist_rejection() {
781 let dir = tempfile::tempdir().unwrap();
782 let script = dir.path().join("evil.rb");
783 fs::write(&script, "#!/usr/bin/ruby\nputs 'hi'").unwrap();
784
785 let allowed = vec!["bash".into(), "python3".into()];
786 let result = check_interpreter(&script, &allowed);
787 assert!(result.is_err());
788 let err_msg = result.unwrap_err().to_string();
789 assert!(err_msg.contains("not in whitelist"));
790 }
791
792 #[tokio::test]
793 async fn timeout_handling() {
794 let dir = tempfile::tempdir().unwrap();
795 let script = dir.path().join("slow.sh");
796 fs::write(&script, "#!/bin/bash\nsleep 60").unwrap();
797 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
798
799 let mut config = test_config();
800 config.script_timeout_seconds = 1;
801 config.skills_dir = dir.path().to_path_buf();
802
803 let runner = ScriptRunner::new(config, test_fs_security());
804 let result = runner.execute(Path::new("slow.sh"), &[]).await;
805
806 assert!(result.is_err());
807 let err_msg = result.unwrap_err().to_string();
808 assert!(err_msg.contains("timed out"));
809 }
810
811 #[tokio::test]
812 async fn rejects_absolute_script_path() {
813 let skills_dir = tempfile::tempdir().unwrap();
814 let outside_dir = tempfile::tempdir().unwrap();
815 let script = outside_dir.path().join("escape.sh");
816 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
817 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
818
819 let mut cfg = test_config();
820 cfg.skills_dir = skills_dir.path().to_path_buf();
821
822 let runner = ScriptRunner::new(cfg, test_fs_security());
823 let result = runner.execute(&script, &[]).await;
824 assert!(result.is_err());
825 let msg = result.unwrap_err().to_string();
826 assert!(msg.contains("absolute script paths are not allowed"));
827 }
828
829 #[test]
830 fn infer_interpreter_from_extension() {
831 let dir = tempfile::tempdir().unwrap();
832
833 let py_script = dir.path().join("test.py");
834 fs::write(&py_script, "print('hi')").unwrap();
835
836 #[cfg(windows)]
837 let allowed = vec![
838 "bash".to_string(),
839 "python".to_string(),
840 "python3".to_string(),
841 "node".to_string(),
842 ];
843 #[cfg(not(windows))]
844 let allowed = vec![
845 "bash".to_string(),
846 "python3".to_string(),
847 "node".to_string(),
848 ];
849
850 let py_result = check_interpreter(&py_script, &allowed).unwrap();
853 #[cfg(windows)]
854 assert!(py_result.ends_with("python") || py_result.ends_with("python.exe"));
855 #[cfg(not(windows))]
856 assert!(
857 Path::new(&py_result).is_absolute() && py_result.contains("python"),
858 "expected absolute python path, got: {py_result}"
859 );
860
861 let sh_script = dir.path().join("test.sh");
862 fs::write(&sh_script, "echo hi").unwrap();
863 let sh_result = check_interpreter(&sh_script, &allowed).unwrap();
864 assert!(
865 sh_result.ends_with("/bash"),
866 "expected absolute bash path, got: {sh_result}"
867 );
868
869 let js_script = dir.path().join("test.js");
870 fs::write(&js_script, "console.log('hi')").unwrap();
871 if let Ok(js_result) = check_interpreter(&js_script, &allowed) {
874 assert!(
875 js_result.ends_with("/node"),
876 "expected absolute node path, got: {js_result}"
877 );
878 }
879 }
880
881 #[test]
882 fn check_interpreter_env_shebang() {
883 let dir = tempfile::tempdir().unwrap();
886 let script = dir.path().join("env_shebang.py");
887 fs::write(&script, "#!/usr/bin/env python3\nprint('hi')").unwrap();
888 let allowed = vec!["python3".to_string()];
889 let interp = check_interpreter(&script, &allowed).unwrap();
890 assert!(
891 Path::new(&interp).is_absolute() && interp.contains("python"),
892 "expected absolute python path, got: {interp}"
893 );
894 }
895
896 #[test]
897 fn check_interpreter_env_shebang_not_allowed() {
898 let dir = tempfile::tempdir().unwrap();
899 let script = dir.path().join("env_ruby.rb");
900 fs::write(&script, "#!/usr/bin/env ruby\nputs 'hi'").unwrap();
901 let allowed = vec!["python3".to_string(), "bash".to_string()];
902 let result = check_interpreter(&script, &allowed);
903 assert!(result.is_err());
904 assert!(result.unwrap_err().to_string().contains("not in whitelist"));
905 }
906
907 #[test]
908 fn check_interpreter_unknown_extension() {
909 let dir = tempfile::tempdir().unwrap();
910 let script = dir.path().join("test.xyz");
911 fs::write(&script, "some content").unwrap();
912 let allowed = vec!["bash".to_string()];
913 let result = check_interpreter(&script, &allowed);
914 assert!(result.is_err());
915 assert!(
916 result
917 .unwrap_err()
918 .to_string()
919 .contains("cannot infer interpreter")
920 );
921 }
922
923 #[test]
924 fn check_interpreter_bash_extension() {
925 let dir = tempfile::tempdir().unwrap();
926 let script = dir.path().join("test.bash");
927 fs::write(&script, "echo hi").unwrap();
928 let allowed = vec!["bash".to_string()];
929 let interp = check_interpreter(&script, &allowed).unwrap();
930 assert!(
931 interp.ends_with("/bash"),
932 "expected absolute bash path, got: {interp}"
933 );
934 }
935
936 #[test]
937 fn world_writable_script_rejected() {
938 let dir = tempfile::tempdir().unwrap();
939 let script = dir.path().join("writable.sh");
940 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
941 fs::set_permissions(&script, fs::Permissions::from_mode(0o777)).unwrap();
942
943 let mut cfg = test_config();
944 cfg.skills_dir = dir.path().to_path_buf();
945 let runner = ScriptRunner::new(cfg, test_fs_security());
946 let result = runner.resolve_script_path(Path::new("writable.sh"));
947 assert!(result.is_err());
948 assert!(result.unwrap_err().to_string().contains("world-writable"));
949 }
950
951 #[test]
952 fn resolve_rejects_directory_traversal() {
953 let dir = tempfile::tempdir().unwrap();
954 let mut cfg = test_config();
955 cfg.skills_dir = dir.path().to_path_buf();
956 let runner = ScriptRunner::new(cfg, test_fs_security());
957
958 let result = runner.resolve_script_path(Path::new("../../etc/passwd"));
960 assert!(result.is_err());
961 }
962
963 #[test]
964 fn resolve_rejects_absolute_path() {
965 let dir = tempfile::tempdir().unwrap();
966 let mut cfg = test_config();
967 cfg.skills_dir = dir.path().to_path_buf();
968 let runner = ScriptRunner::new(cfg, test_fs_security());
969
970 let result = runner.resolve_script_path(Path::new("/etc/passwd"));
971 assert!(result.is_err());
972 assert!(
973 result
974 .unwrap_err()
975 .to_string()
976 .contains("absolute script paths")
977 );
978 }
979
980 #[test]
981 fn truncate_str_within_limit() {
982 let s = "hello world";
983 assert_eq!(truncate_str(s, 100), "hello world");
984 }
985
986 #[test]
987 fn truncate_str_at_limit() {
988 let s = "hello";
989 assert_eq!(truncate_str(s, 5), "hello");
990 }
991
992 #[test]
993 fn truncate_str_beyond_limit() {
994 let s = "hello world";
995 let truncated = truncate_str(s, 5);
996 assert_eq!(truncated, "hello");
997 }
998
999 #[test]
1000 fn truncate_str_multibyte_boundary() {
1001 let s = "café";
1003 let truncated = truncate_str(s, 4);
1004 assert_eq!(truncated, "caf");
1007 }
1008
1009 #[tokio::test]
1010 async fn script_with_args() {
1011 let dir = tempfile::tempdir().unwrap();
1012 let script = dir.path().join("args.sh");
1013 fs::write(&script, "#!/bin/bash\necho \"$1 $2\"").unwrap();
1014 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1015
1016 let mut cfg = test_config();
1017 cfg.skills_dir = dir.path().to_path_buf();
1018 let runner = ScriptRunner::new(cfg, test_fs_security());
1019 let result = runner
1020 .execute(Path::new("args.sh"), &["hello", "world"])
1021 .await
1022 .unwrap();
1023
1024 assert_eq!(result.exit_code, 0);
1025 assert!(result.stdout.contains("hello world"));
1026 }
1027
1028 #[tokio::test]
1029 async fn script_nonzero_exit_code() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let script = dir.path().join("fail.sh");
1032 fs::write(&script, "#!/bin/bash\nexit 42").unwrap();
1033 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1034
1035 let mut cfg = test_config();
1036 cfg.skills_dir = dir.path().to_path_buf();
1037 let runner = ScriptRunner::new(cfg, test_fs_security());
1038 let result = runner.execute(Path::new("fail.sh"), &[]).await.unwrap();
1039
1040 assert_eq!(result.exit_code, 42);
1041 }
1042
1043 #[tokio::test]
1044 async fn script_output_truncation() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let script = dir.path().join("verbose.sh");
1047 fs::write(&script, "#!/bin/bash\nfor i in $(seq 1 500); do echo \"line $i with some padding text to fill up space\"; done").unwrap();
1049 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1050
1051 let mut cfg = test_config();
1052 cfg.skills_dir = dir.path().to_path_buf();
1053 let runner = ScriptRunner::new(cfg, test_fs_security());
1054 let result = runner.execute(Path::new("verbose.sh"), &[]).await.unwrap();
1055
1056 assert!(
1057 result.stdout.len() <= 1024,
1058 "stdout should be truncated to max_output_bytes"
1059 );
1060 }
1061
1062 #[tokio::test]
1063 async fn sandbox_env_strips_secrets() {
1064 let _guard = EnvGuard::set("OPENAI_API_KEY", "top-secret-test-value");
1065
1066 let dir = tempfile::tempdir().unwrap();
1067 let script = dir.path().join("print_secret.sh");
1068 fs::write(
1069 &script,
1070 "#!/bin/bash\nprintf \"%s\" \"${OPENAI_API_KEY:-MISSING}\"",
1071 )
1072 .unwrap();
1073 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1074
1075 let mut cfg = test_config();
1076 cfg.sandbox_env = true;
1077 cfg.skills_dir = dir.path().to_path_buf();
1078 let runner = ScriptRunner::new(cfg, test_fs_security());
1079 let result = runner
1080 .execute(Path::new("print_secret.sh"), &[])
1081 .await
1082 .expect("script should execute");
1083
1084 assert_eq!(result.exit_code, 0);
1085 assert_eq!(
1086 result.stdout.trim(),
1087 "MISSING",
1088 "sandboxed script must not inherit secret env vars"
1089 );
1090 }
1091
1092 #[test]
1093 fn resolve_interpreter_absolute_finds_bash() {
1094 let abs = resolve_interpreter_absolute("bash").unwrap();
1095 assert!(
1096 Path::new(&abs).is_absolute(),
1097 "expected absolute path, got: {abs}"
1098 );
1099 assert!(
1100 abs.ends_with("/bash"),
1101 "expected path ending in /bash, got: {abs}"
1102 );
1103 }
1104
1105 #[test]
1106 fn resolve_interpreter_absolute_rejects_missing() {
1107 let result = resolve_interpreter_absolute("nonexistent_binary_xyz_123");
1108 assert!(result.is_err());
1109 assert!(
1110 result
1111 .unwrap_err()
1112 .to_string()
1113 .contains("not found in PATH")
1114 );
1115 }
1116
1117 #[tokio::test]
1118 async fn sandbox_exposes_workspace_env_vars() {
1119 let dir = tempfile::tempdir().unwrap();
1120 let ws_dir = tempfile::tempdir().unwrap();
1121 let script = dir.path().join("check_ws.sh");
1122 fs::write(
1123 &script,
1124 "#!/bin/bash\nprintf \"SKILLS=%s WS=%s\" \"${ROBOTICUS_SKILLS_DIR:-MISSING}\" \"${ROBOTICUS_WORKSPACE:-MISSING}\"",
1125 )
1126 .unwrap();
1127 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1128
1129 let mut cfg = test_config();
1130 cfg.skills_dir = dir.path().to_path_buf();
1131 cfg.workspace_dir = Some(ws_dir.path().to_path_buf());
1132 let runner = ScriptRunner::new(cfg, test_fs_security());
1133 let result = runner
1134 .execute(Path::new("check_ws.sh"), &[])
1135 .await
1136 .expect("script should execute");
1137
1138 assert_eq!(result.exit_code, 0);
1139 assert!(
1140 result
1141 .stdout
1142 .contains(&format!("SKILLS={}", dir.path().display())),
1143 "ROBOTICUS_SKILLS_DIR not set, got: {}",
1144 result.stdout
1145 );
1146 assert!(
1147 result
1148 .stdout
1149 .contains(&format!("WS={}", ws_dir.path().display())),
1150 "ROBOTICUS_WORKSPACE not set, got: {}",
1151 result.stdout
1152 );
1153 }
1154
1155 #[tokio::test]
1156 async fn sandbox_env_keeps_minimal_runtime_vars_only() {
1157 let _g1 = EnvGuard::set("SECRET_TOKEN", "definitely-secret");
1158 let _g2 = EnvGuard::set("LANG", "en_US.UTF-8");
1159
1160 let dir = tempfile::tempdir().unwrap();
1161 let script = dir.path().join("print_env_subset.sh");
1162 fs::write(
1163 &script,
1164 "#!/bin/bash\nprintf \"PATH=%s\\nHOME=%s\\nTMP=%s\\nLANG=%s\\nTOKEN=%s\" \"${PATH:-}\" \"${HOME:-}\" \"${TMP:-}\" \"${LANG:-}\" \"${SECRET_TOKEN:-MISSING}\"",
1165 )
1166 .unwrap();
1167 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1168
1169 let mut cfg = test_config();
1170 cfg.sandbox_env = true;
1171 cfg.skills_dir = dir.path().to_path_buf();
1172 let runner = ScriptRunner::new(cfg, test_fs_security());
1173 let result = runner
1174 .execute(Path::new("print_env_subset.sh"), &[])
1175 .await
1176 .expect("script should execute");
1177
1178 assert_eq!(result.exit_code, 0);
1179 assert!(result.stdout.contains("PATH="));
1180 assert!(result.stdout.contains("HOME="));
1181 assert!(result.stdout.contains("TMP="));
1182 assert!(result.stdout.contains("LANG=en_US.UTF-8"));
1183 assert!(
1184 result.stdout.ends_with("TOKEN=MISSING"),
1185 "non-allowlisted secrets must not be present"
1186 );
1187 }
1188
1189 #[cfg(target_os = "macos")]
1190 #[test]
1191 fn sandbox_profile_contains_expected_rules() {
1192 use std::io::Read;
1193
1194 let skills = tempfile::tempdir().unwrap();
1195 let workspace = tempfile::tempdir().unwrap();
1196 let extra = tempfile::tempdir().unwrap();
1197
1198 let profile = generate_sandbox_profile(
1199 skills.path(),
1200 Some(workspace.path()),
1201 &[extra.path().to_path_buf()],
1202 false,
1203 )
1204 .unwrap();
1205
1206 let mut contents = String::new();
1207 std::fs::File::open(profile.path())
1208 .unwrap()
1209 .read_to_string(&mut contents)
1210 .unwrap();
1211
1212 assert!(contents.contains("(version 1)"), "missing version");
1213 assert!(contents.contains("(deny default)"), "missing deny default");
1214
1215 assert!(
1217 contents.contains("(allow file-read*)"),
1218 "should allow global reads: {contents}"
1219 );
1220
1221 let workspace_canon = workspace.path().canonicalize().unwrap();
1223 let extra_canon = extra.path().canonicalize().unwrap();
1224 assert!(
1225 contents.contains(&format!(
1226 "(allow file-write* (subpath \"{}\"))",
1227 workspace_canon.display()
1228 )),
1229 "workspace_dir not in write rules: {contents}"
1230 );
1231 assert!(
1232 contents.contains(&format!(
1233 "(allow file-write* (subpath \"{}\"))",
1234 extra_canon.display()
1235 )),
1236 "extra path not in write rules: {contents}"
1237 );
1238
1239 assert!(
1241 contents.contains("(allow file-write* (subpath \"/tmp\"))"),
1242 "/tmp not writable: {contents}"
1243 );
1244
1245 assert!(
1247 !contents.contains("(allow network"),
1248 "network should be denied"
1249 );
1250 assert!(
1251 contents.contains("Network denied"),
1252 "should note network denial"
1253 );
1254 }
1255
1256 #[cfg(target_os = "macos")]
1257 #[test]
1258 fn sandbox_profile_allows_network_when_configured() {
1259 use std::io::Read;
1260
1261 let skills = tempfile::tempdir().unwrap();
1262 let profile = generate_sandbox_profile(skills.path(), None, &[], true).unwrap();
1263
1264 let mut contents = String::new();
1265 std::fs::File::open(profile.path())
1266 .unwrap()
1267 .read_to_string(&mut contents)
1268 .unwrap();
1269
1270 assert!(
1271 contents.contains("(allow network*)"),
1272 "network should be allowed when network_allowed=true"
1273 );
1274 }
1275
1276 #[cfg(target_os = "macos")]
1277 #[tokio::test]
1278 async fn sandbox_exec_confines_script_filesystem() {
1279 let skills_dir = tempfile::tempdir().unwrap();
1283 let forbidden_dir = tempfile::tempdir().unwrap();
1284 let forbidden_file = forbidden_dir.path().join("should_not_exist.txt");
1285
1286 let script = skills_dir.path().join("write_outside.sh");
1287 fs::write(
1288 &script,
1289 format!(
1290 "#!/bin/bash\necho 'breach' > '{}' 2>/dev/null && echo WRITTEN || echo BLOCKED",
1291 forbidden_file.display()
1292 ),
1293 )
1294 .unwrap();
1295 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1296
1297 let mut cfg = test_config();
1298 cfg.skills_dir = skills_dir.path().to_path_buf();
1299 cfg.sandbox_env = true;
1300
1301 let fs_sec = FilesystemSecurityConfig {
1302 script_fs_confinement: true,
1303 ..Default::default()
1304 };
1305
1306 let runner = ScriptRunner::new(cfg, fs_sec);
1307 let result = runner
1308 .execute(Path::new("write_outside.sh"), &[])
1309 .await
1310 .unwrap();
1311
1312 if result.exit_code == 71
1313 && result
1314 .stderr
1315 .contains("sandbox_apply: Operation not permitted")
1316 {
1317 return;
1318 }
1319
1320 assert!(
1321 result.stdout.contains("BLOCKED"),
1322 "sandbox should block writes outside allowed paths, stdout={:?} stderr={:?} exit={}",
1323 result.stdout,
1324 result.stderr,
1325 result.exit_code
1326 );
1327 assert!(
1328 !forbidden_file.exists(),
1329 "file should not have been created outside sandbox"
1330 );
1331 }
1332}