1use std::path::Path;
2
3use tokio::process::Command;
4
5use roboticus_core::config::{FilesystemSecurityConfig, SkillsConfig};
6use roboticus_core::{RoboticusError, Result};
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 _sandbox_profile = None;
66 cmd = Command::new(&interpreter);
67 cmd.arg(&script_path).args(args).current_dir(working_dir);
68 }
69 }
70
71 #[cfg(not(target_os = "macos"))]
72 {
73 cmd = Command::new(&interpreter);
74 cmd.arg(&script_path).args(args).current_dir(working_dir);
75 }
76
77 if self.config.sandbox_env {
78 cmd.env_clear();
79 if let Ok(path) = std::env::var("PATH") {
80 cmd.env("PATH", path);
81 }
82 if let Some(home) = default_home_env() {
83 cmd.env("HOME", home);
84 }
85 for key in ["USERPROFILE", "TMPDIR", "TMP", "TEMP", "LANG", "TERM"] {
86 if let Ok(val) = std::env::var(key) {
87 cmd.env(key, val);
88 }
89 }
90 cmd.env("ROBOTICUS_SKILLS_DIR", &self.config.skills_dir);
93 if let Some(ref ws) = self.config.workspace_dir {
94 cmd.env("ROBOTICUS_WORKSPACE", ws);
95 }
96 }
97
98 #[cfg(unix)]
100 {
101 let mem_limit = self.config.script_max_memory_bytes;
102 let deny_net = self.config.sandbox_env && !self.config.network_allowed;
103 unsafe {
106 cmd.pre_exec(move || {
107 #[cfg(target_os = "linux")]
112 if let Some(max_bytes) = mem_limit {
113 let rlim = libc::rlimit {
114 rlim_cur: max_bytes,
115 rlim_max: max_bytes,
116 };
117 if libc::setrlimit(libc::RLIMIT_AS, &rlim) != 0 {
118 return Err(std::io::Error::last_os_error());
119 }
120 }
121 #[cfg(not(target_os = "linux"))]
122 let _ = mem_limit;
123 #[cfg(target_os = "linux")]
125 if deny_net && libc::unshare(libc::CLONE_NEWNET) != 0 {
126 eprintln!(
129 "roboticus: warning: network isolation unavailable (unshare failed)"
130 );
131 }
132 #[cfg(not(target_os = "linux"))]
135 let _ = deny_net;
136 Ok(())
137 });
138 }
139 }
140
141 cmd.stdout(std::process::Stdio::piped());
142 cmd.stderr(std::process::Stdio::piped());
143
144 let timeout_dur = std::time::Duration::from_secs(self.config.script_timeout_seconds);
145 let start = std::time::Instant::now();
146 let max = self.config.script_max_output_bytes;
147 let max_capture = (max as u64).saturating_add(1);
148
149 let mut child = cmd.spawn().map_err(|e| RoboticusError::Tool {
150 tool: "script_runner".into(),
151 message: format!("failed to spawn {interpreter}: {e}"),
152 })?;
153 let stdout = child.stdout.take().ok_or_else(|| RoboticusError::Tool {
154 tool: "script_runner".into(),
155 message: "failed to capture script stdout".into(),
156 })?;
157 let stderr = child.stderr.take().ok_or_else(|| RoboticusError::Tool {
158 tool: "script_runner".into(),
159 message: "failed to capture script stderr".into(),
160 })?;
161 let stdout_task = tokio::spawn(async move {
162 use tokio::io::AsyncReadExt;
163 let mut buf = Vec::new();
164 let _ = stdout.take(max_capture).read_to_end(&mut buf).await;
165 buf
166 });
167 let stderr_task = tokio::spawn(async move {
168 use tokio::io::AsyncReadExt;
169 let mut buf = Vec::new();
170 let _ = stderr.take(max_capture).read_to_end(&mut buf).await;
171 buf
172 });
173
174 let status = match tokio::time::timeout(timeout_dur, child.wait()).await {
175 Ok(Ok(status)) => status,
176 Ok(Err(e)) => {
177 return Err(RoboticusError::Tool {
178 tool: "script_runner".into(),
179 message: format!("process error: {e}"),
180 });
181 }
182 Err(_) => {
183 let _ = child.kill().await;
184 let _ = child.wait().await;
185 return Err(RoboticusError::Tool {
186 tool: "script_runner".into(),
187 message: format!(
188 "script timed out after {}s",
189 self.config.script_timeout_seconds
190 ),
191 });
192 }
193 };
194
195 let duration_ms = start.elapsed().as_millis() as u64;
196 let stdout_bytes = stdout_task.await.unwrap_or_default();
197 let stderr_bytes = stderr_task.await.unwrap_or_default();
198 let stdout_raw = String::from_utf8_lossy(&stdout_bytes);
199 let stderr_raw = String::from_utf8_lossy(&stderr_bytes);
200
201 let stdout = truncate_str(&stdout_raw, max);
202 let stderr = truncate_str(&stderr_raw, max);
203
204 Ok(ScriptResult {
205 stdout,
206 stderr,
207 exit_code: status.code().unwrap_or(-1),
208 duration_ms,
209 })
210 }
211
212 pub fn resolve_script_path(&self, requested: &Path) -> Result<std::path::PathBuf> {
216 if requested.is_absolute() {
217 return Err(RoboticusError::Config(
218 "absolute script paths are not allowed".into(),
219 ));
220 }
221
222 let root =
223 std::fs::canonicalize(&self.config.skills_dir).map_err(|e| RoboticusError::Tool {
224 tool: "script_runner".into(),
225 message: format!(
226 "failed to resolve skills_dir '{}': {e}",
227 self.config.skills_dir.display()
228 ),
229 })?;
230 let joined = root.join(requested);
231 let canonical = std::fs::canonicalize(&joined).map_err(|e| RoboticusError::Tool {
232 tool: "script_runner".into(),
233 message: format!("failed to resolve script path '{}': {e}", joined.display()),
234 })?;
235 if !canonical.starts_with(&root) {
236 return Err(RoboticusError::Tool {
237 tool: "script_runner".into(),
238 message: format!(
239 "script path '{}' escapes skills_dir '{}'",
240 canonical.display(),
241 root.display()
242 ),
243 });
244 }
245 if !canonical.is_file() {
246 return Err(RoboticusError::Tool {
247 tool: "script_runner".into(),
248 message: format!("script path '{}' is not a file", canonical.display()),
249 });
250 }
251
252 #[cfg(unix)]
253 {
254 use std::os::unix::fs::PermissionsExt;
255 let metadata = std::fs::metadata(&canonical).map_err(|e| RoboticusError::Tool {
256 tool: "script_runner".into(),
257 message: format!("failed to read metadata for '{}': {e}", canonical.display()),
258 })?;
259 let mode = metadata.permissions().mode();
260 if mode & 0o002 != 0 {
261 return Err(RoboticusError::Tool {
262 tool: "script_runner".into(),
263 message: format!(
264 "script '{}' is world-writable (mode {:o})",
265 canonical.display(),
266 mode
267 ),
268 });
269 }
270 }
271
272 Ok(canonical)
273 }
274}
275
276#[cfg(target_os = "macos")]
288fn generate_sandbox_profile(
289 _skills_dir: &Path,
290 workspace_dir: Option<&Path>,
291 extra_paths: &[std::path::PathBuf],
292 network_allowed: bool,
293) -> Result<tempfile::NamedTempFile> {
294 use std::io::Write;
295
296 let canon = |p: &Path| -> String {
300 p.canonicalize()
301 .unwrap_or_else(|_| p.to_path_buf())
302 .display()
303 .to_string()
304 };
305
306 let mut profile = tempfile::NamedTempFile::new().map_err(|e| RoboticusError::Tool {
307 tool: "script_runner".into(),
308 message: format!("failed to create sandbox profile tempfile: {e}"),
309 })?;
310
311 let mut sb = String::with_capacity(2048);
321 sb.push_str("(version 1)\n");
322 sb.push_str("(deny default)\n\n");
323
324 sb.push_str("; Process execution for interpreters\n");
326 sb.push_str("(allow process-exec)\n");
327 sb.push_str("(allow process-fork)\n\n");
328
329 sb.push_str("; Global read access — writes are the confinement boundary\n");
333 sb.push_str("(allow file-read*)\n\n");
334
335 sb.push_str("; /dev/null, /dev/zero — scripts redirect stderr here\n");
338 sb.push_str("(allow file-write* (literal \"/dev/null\") (literal \"/dev/zero\"))\n\n");
339
340 sb.push_str("; Scratch space — /tmp and /private/tmp\n");
341 sb.push_str("(allow file-write* (subpath \"/tmp\"))\n");
342 sb.push_str("(allow file-write* (subpath \"/private/tmp\"))\n\n");
343
344 if let Some(ws) = workspace_dir {
346 sb.push_str("; Workspace directory — writable\n");
347 sb.push_str(&format!(
348 "(allow file-write* (subpath \"{}\"))\n\n",
349 canon(ws)
350 ));
351 }
352
353 for p in extra_paths {
355 sb.push_str(&format!("(allow file-write* (subpath \"{}\"))\n", canon(p)));
356 }
357 if !extra_paths.is_empty() {
358 sb.push('\n');
359 }
360
361 sb.push_str("; IPC and signals for language runtimes\n");
364 sb.push_str("(allow sysctl-read)\n");
365 sb.push_str("(allow mach-lookup)\n");
366 sb.push_str("(allow signal (target self))\n");
367 sb.push_str("(allow ipc-posix-shm-read-data)\n");
368 sb.push_str("(allow ipc-posix-shm-write-data)\n\n");
369
370 if network_allowed {
374 sb.push_str("; Network access allowed by configuration\n");
375 sb.push_str("(allow network*)\n");
376 } else {
377 sb.push_str("; Network denied (sandbox_env + !network_allowed)\n");
378 }
379
380 profile
381 .write_all(sb.as_bytes())
382 .map_err(|e| RoboticusError::Tool {
383 tool: "script_runner".into(),
384 message: format!("failed to write sandbox profile: {e}"),
385 })?;
386
387 Ok(profile)
388}
389
390fn truncate_str(s: &str, max_bytes: usize) -> String {
391 if s.len() <= max_bytes {
392 s.to_string()
393 } else {
394 let mut end = max_bytes;
395 while end > 0 && !s.is_char_boundary(end) {
396 end -= 1;
397 }
398 s[..end].to_string()
399 }
400}
401
402fn default_home_env() -> Option<String> {
403 std::env::var("HOME")
404 .ok()
405 .or_else(|| std::env::var("USERPROFILE").ok())
406}
407
408fn default_python_interpreter() -> &'static str {
409 #[cfg(windows)]
410 {
411 "python"
412 }
413 #[cfg(not(windows))]
414 {
415 "python3"
416 }
417}
418
419pub fn resolve_interpreter_absolute(name: &str) -> Result<String> {
425 let p = Path::new(name);
426 if p.is_absolute() {
427 let canonical = std::fs::canonicalize(p).map_err(|e| RoboticusError::Tool {
428 tool: "script_runner".into(),
429 message: format!("interpreter '{name}' not found: {e}"),
430 })?;
431 return Ok(canonical.to_string_lossy().to_string());
432 }
433 let path_var = std::env::var("PATH").unwrap_or_default();
434 for dir in std::env::split_paths(&path_var) {
435 let candidate = dir.join(name);
436 if candidate.is_file()
437 && let Ok(canonical) = std::fs::canonicalize(&candidate)
438 {
439 return Ok(canonical.to_string_lossy().to_string());
440 }
441 }
442 Err(RoboticusError::Tool {
443 tool: "script_runner".into(),
444 message: format!("interpreter '{name}' not found in PATH"),
445 })
446}
447
448pub fn check_interpreter(script_path: &Path, allowed: &[String]) -> Result<String> {
452 if let Ok(first_line) = std::fs::File::open(script_path).and_then(|f| {
453 use std::io::{BufRead, Read};
454 let mut line = String::new();
455 std::io::BufReader::new(f.take(512)).read_line(&mut line)?;
456 Ok(line)
457 }) && first_line.starts_with("#!")
458 {
459 let shebang = first_line[2..].trim();
460 let interpreter = shebang
461 .split('/')
462 .next_back()
463 .unwrap_or(shebang)
464 .split_whitespace()
465 .next()
466 .unwrap_or(shebang);
467
468 let interp = if interpreter == "env" {
469 shebang.split_whitespace().nth(1).unwrap_or(interpreter)
470 } else {
471 interpreter
472 };
473
474 if allowed.iter().any(|a| a == interp) {
475 return resolve_interpreter_absolute(interp);
476 } else {
477 return Err(RoboticusError::Tool {
478 tool: "script_runner".into(),
479 message: format!("interpreter '{interp}' not in whitelist: {allowed:?}"),
480 });
481 }
482 }
483
484 let ext = script_path
485 .extension()
486 .and_then(|e| e.to_str())
487 .unwrap_or("");
488
489 let inferred = match ext {
490 "py" => default_python_interpreter(),
491 "sh" | "bash" => "bash",
492 "js" => "node",
493 _ => {
494 return Err(RoboticusError::Tool {
495 tool: "script_runner".into(),
496 message: format!("cannot infer interpreter for extension '.{ext}'"),
497 });
498 }
499 };
500
501 if allowed.iter().any(|a| a == inferred) {
502 resolve_interpreter_absolute(inferred)
503 } else {
504 Err(RoboticusError::Tool {
505 tool: "script_runner".into(),
506 message: format!("interpreter '{inferred}' not in whitelist: {allowed:?}"),
507 })
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::test_support::EnvGuard;
515 use std::fs;
516 use std::os::unix::fs::PermissionsExt;
517
518 fn test_config() -> SkillsConfig {
519 SkillsConfig {
520 script_timeout_seconds: 5,
521 script_max_output_bytes: 1024,
522 allowed_interpreters: vec!["bash".into(), "python3".into(), "node".into()],
523 sandbox_env: true,
524 ..Default::default()
525 }
526 }
527
528 fn test_fs_security() -> FilesystemSecurityConfig {
529 FilesystemSecurityConfig {
530 script_fs_confinement: false,
533 ..Default::default()
534 }
535 }
536
537 #[tokio::test]
538 async fn successful_script_execution() {
539 let dir = tempfile::tempdir().unwrap();
540 let script = dir.path().join("test.sh");
541 fs::write(&script, "#!/bin/bash\necho \"hello from script\"").unwrap();
542 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
543
544 let mut cfg = test_config();
545 cfg.skills_dir = dir.path().to_path_buf();
546 let runner = ScriptRunner::new(cfg, test_fs_security());
547 let result = runner.execute(Path::new("test.sh"), &[]).await.unwrap();
548
549 assert_eq!(result.exit_code, 0);
550 assert!(result.stdout.contains("hello from script"));
551 }
552
553 #[test]
554 fn interpreter_whitelist_rejection() {
555 let dir = tempfile::tempdir().unwrap();
556 let script = dir.path().join("evil.rb");
557 fs::write(&script, "#!/usr/bin/ruby\nputs 'hi'").unwrap();
558
559 let allowed = vec!["bash".into(), "python3".into()];
560 let result = check_interpreter(&script, &allowed);
561 assert!(result.is_err());
562 let err_msg = result.unwrap_err().to_string();
563 assert!(err_msg.contains("not in whitelist"));
564 }
565
566 #[tokio::test]
567 async fn timeout_handling() {
568 let dir = tempfile::tempdir().unwrap();
569 let script = dir.path().join("slow.sh");
570 fs::write(&script, "#!/bin/bash\nsleep 60").unwrap();
571 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
572
573 let mut config = test_config();
574 config.script_timeout_seconds = 1;
575 config.skills_dir = dir.path().to_path_buf();
576
577 let runner = ScriptRunner::new(config, test_fs_security());
578 let result = runner.execute(Path::new("slow.sh"), &[]).await;
579
580 assert!(result.is_err());
581 let err_msg = result.unwrap_err().to_string();
582 assert!(err_msg.contains("timed out"));
583 }
584
585 #[tokio::test]
586 async fn rejects_absolute_script_path() {
587 let skills_dir = tempfile::tempdir().unwrap();
588 let outside_dir = tempfile::tempdir().unwrap();
589 let script = outside_dir.path().join("escape.sh");
590 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
591 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
592
593 let mut cfg = test_config();
594 cfg.skills_dir = skills_dir.path().to_path_buf();
595
596 let runner = ScriptRunner::new(cfg, test_fs_security());
597 let result = runner.execute(&script, &[]).await;
598 assert!(result.is_err());
599 let msg = result.unwrap_err().to_string();
600 assert!(msg.contains("absolute script paths are not allowed"));
601 }
602
603 #[test]
604 fn infer_interpreter_from_extension() {
605 let dir = tempfile::tempdir().unwrap();
606
607 let py_script = dir.path().join("test.py");
608 fs::write(&py_script, "print('hi')").unwrap();
609
610 #[cfg(windows)]
611 let allowed = vec![
612 "bash".to_string(),
613 "python".to_string(),
614 "python3".to_string(),
615 "node".to_string(),
616 ];
617 #[cfg(not(windows))]
618 let allowed = vec![
619 "bash".to_string(),
620 "python3".to_string(),
621 "node".to_string(),
622 ];
623
624 let py_result = check_interpreter(&py_script, &allowed).unwrap();
627 #[cfg(windows)]
628 assert!(py_result.ends_with("python") || py_result.ends_with("python.exe"));
629 #[cfg(not(windows))]
630 assert!(
631 Path::new(&py_result).is_absolute() && py_result.contains("python"),
632 "expected absolute python path, got: {py_result}"
633 );
634
635 let sh_script = dir.path().join("test.sh");
636 fs::write(&sh_script, "echo hi").unwrap();
637 let sh_result = check_interpreter(&sh_script, &allowed).unwrap();
638 assert!(
639 sh_result.ends_with("/bash"),
640 "expected absolute bash path, got: {sh_result}"
641 );
642
643 let js_script = dir.path().join("test.js");
644 fs::write(&js_script, "console.log('hi')").unwrap();
645 let js_result = check_interpreter(&js_script, &allowed).unwrap();
646 assert!(
647 js_result.ends_with("/node"),
648 "expected absolute node path, got: {js_result}"
649 );
650 }
651
652 #[test]
653 fn check_interpreter_env_shebang() {
654 let dir = tempfile::tempdir().unwrap();
657 let script = dir.path().join("env_shebang.py");
658 fs::write(&script, "#!/usr/bin/env python3\nprint('hi')").unwrap();
659 let allowed = vec!["python3".to_string()];
660 let interp = check_interpreter(&script, &allowed).unwrap();
661 assert!(
662 Path::new(&interp).is_absolute() && interp.contains("python"),
663 "expected absolute python path, got: {interp}"
664 );
665 }
666
667 #[test]
668 fn check_interpreter_env_shebang_not_allowed() {
669 let dir = tempfile::tempdir().unwrap();
670 let script = dir.path().join("env_ruby.rb");
671 fs::write(&script, "#!/usr/bin/env ruby\nputs 'hi'").unwrap();
672 let allowed = vec!["python3".to_string(), "bash".to_string()];
673 let result = check_interpreter(&script, &allowed);
674 assert!(result.is_err());
675 assert!(result.unwrap_err().to_string().contains("not in whitelist"));
676 }
677
678 #[test]
679 fn check_interpreter_unknown_extension() {
680 let dir = tempfile::tempdir().unwrap();
681 let script = dir.path().join("test.xyz");
682 fs::write(&script, "some content").unwrap();
683 let allowed = vec!["bash".to_string()];
684 let result = check_interpreter(&script, &allowed);
685 assert!(result.is_err());
686 assert!(
687 result
688 .unwrap_err()
689 .to_string()
690 .contains("cannot infer interpreter")
691 );
692 }
693
694 #[test]
695 fn check_interpreter_bash_extension() {
696 let dir = tempfile::tempdir().unwrap();
697 let script = dir.path().join("test.bash");
698 fs::write(&script, "echo hi").unwrap();
699 let allowed = vec!["bash".to_string()];
700 let interp = check_interpreter(&script, &allowed).unwrap();
701 assert!(
702 interp.ends_with("/bash"),
703 "expected absolute bash path, got: {interp}"
704 );
705 }
706
707 #[test]
708 fn world_writable_script_rejected() {
709 let dir = tempfile::tempdir().unwrap();
710 let script = dir.path().join("writable.sh");
711 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
712 fs::set_permissions(&script, fs::Permissions::from_mode(0o777)).unwrap();
713
714 let mut cfg = test_config();
715 cfg.skills_dir = dir.path().to_path_buf();
716 let runner = ScriptRunner::new(cfg, test_fs_security());
717 let result = runner.resolve_script_path(Path::new("writable.sh"));
718 assert!(result.is_err());
719 assert!(result.unwrap_err().to_string().contains("world-writable"));
720 }
721
722 #[test]
723 fn resolve_rejects_directory_traversal() {
724 let dir = tempfile::tempdir().unwrap();
725 let mut cfg = test_config();
726 cfg.skills_dir = dir.path().to_path_buf();
727 let runner = ScriptRunner::new(cfg, test_fs_security());
728
729 let result = runner.resolve_script_path(Path::new("../../etc/passwd"));
731 assert!(result.is_err());
732 }
733
734 #[test]
735 fn resolve_rejects_absolute_path() {
736 let dir = tempfile::tempdir().unwrap();
737 let mut cfg = test_config();
738 cfg.skills_dir = dir.path().to_path_buf();
739 let runner = ScriptRunner::new(cfg, test_fs_security());
740
741 let result = runner.resolve_script_path(Path::new("/etc/passwd"));
742 assert!(result.is_err());
743 assert!(
744 result
745 .unwrap_err()
746 .to_string()
747 .contains("absolute script paths")
748 );
749 }
750
751 #[test]
752 fn truncate_str_within_limit() {
753 let s = "hello world";
754 assert_eq!(truncate_str(s, 100), "hello world");
755 }
756
757 #[test]
758 fn truncate_str_at_limit() {
759 let s = "hello";
760 assert_eq!(truncate_str(s, 5), "hello");
761 }
762
763 #[test]
764 fn truncate_str_beyond_limit() {
765 let s = "hello world";
766 let truncated = truncate_str(s, 5);
767 assert_eq!(truncated, "hello");
768 }
769
770 #[test]
771 fn truncate_str_multibyte_boundary() {
772 let s = "café";
774 let truncated = truncate_str(s, 4);
775 assert_eq!(truncated, "caf");
778 }
779
780 #[tokio::test]
781 async fn script_with_args() {
782 let dir = tempfile::tempdir().unwrap();
783 let script = dir.path().join("args.sh");
784 fs::write(&script, "#!/bin/bash\necho \"$1 $2\"").unwrap();
785 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
786
787 let mut cfg = test_config();
788 cfg.skills_dir = dir.path().to_path_buf();
789 let runner = ScriptRunner::new(cfg, test_fs_security());
790 let result = runner
791 .execute(Path::new("args.sh"), &["hello", "world"])
792 .await
793 .unwrap();
794
795 assert_eq!(result.exit_code, 0);
796 assert!(result.stdout.contains("hello world"));
797 }
798
799 #[tokio::test]
800 async fn script_nonzero_exit_code() {
801 let dir = tempfile::tempdir().unwrap();
802 let script = dir.path().join("fail.sh");
803 fs::write(&script, "#!/bin/bash\nexit 42").unwrap();
804 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
805
806 let mut cfg = test_config();
807 cfg.skills_dir = dir.path().to_path_buf();
808 let runner = ScriptRunner::new(cfg, test_fs_security());
809 let result = runner.execute(Path::new("fail.sh"), &[]).await.unwrap();
810
811 assert_eq!(result.exit_code, 42);
812 }
813
814 #[tokio::test]
815 async fn script_output_truncation() {
816 let dir = tempfile::tempdir().unwrap();
817 let script = dir.path().join("verbose.sh");
818 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();
820 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
821
822 let mut cfg = test_config();
823 cfg.skills_dir = dir.path().to_path_buf();
824 let runner = ScriptRunner::new(cfg, test_fs_security());
825 let result = runner.execute(Path::new("verbose.sh"), &[]).await.unwrap();
826
827 assert!(
828 result.stdout.len() <= 1024,
829 "stdout should be truncated to max_output_bytes"
830 );
831 }
832
833 #[tokio::test]
834 async fn sandbox_env_strips_secrets() {
835 let _guard = EnvGuard::set("OPENAI_API_KEY", "top-secret-test-value");
836
837 let dir = tempfile::tempdir().unwrap();
838 let script = dir.path().join("print_secret.sh");
839 fs::write(
840 &script,
841 "#!/bin/bash\nprintf \"%s\" \"${OPENAI_API_KEY:-MISSING}\"",
842 )
843 .unwrap();
844 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
845
846 let mut cfg = test_config();
847 cfg.sandbox_env = true;
848 cfg.skills_dir = dir.path().to_path_buf();
849 let runner = ScriptRunner::new(cfg, test_fs_security());
850 let result = runner
851 .execute(Path::new("print_secret.sh"), &[])
852 .await
853 .expect("script should execute");
854
855 assert_eq!(result.exit_code, 0);
856 assert_eq!(
857 result.stdout.trim(),
858 "MISSING",
859 "sandboxed script must not inherit secret env vars"
860 );
861 }
862
863 #[test]
864 fn resolve_interpreter_absolute_finds_bash() {
865 let abs = resolve_interpreter_absolute("bash").unwrap();
866 assert!(
867 Path::new(&abs).is_absolute(),
868 "expected absolute path, got: {abs}"
869 );
870 assert!(
871 abs.ends_with("/bash"),
872 "expected path ending in /bash, got: {abs}"
873 );
874 }
875
876 #[test]
877 fn resolve_interpreter_absolute_rejects_missing() {
878 let result = resolve_interpreter_absolute("nonexistent_binary_xyz_123");
879 assert!(result.is_err());
880 assert!(
881 result
882 .unwrap_err()
883 .to_string()
884 .contains("not found in PATH")
885 );
886 }
887
888 #[tokio::test]
889 async fn sandbox_exposes_workspace_env_vars() {
890 let dir = tempfile::tempdir().unwrap();
891 let ws_dir = tempfile::tempdir().unwrap();
892 let script = dir.path().join("check_ws.sh");
893 fs::write(
894 &script,
895 "#!/bin/bash\nprintf \"SKILLS=%s WS=%s\" \"${ROBOTICUS_SKILLS_DIR:-MISSING}\" \"${ROBOTICUS_WORKSPACE:-MISSING}\"",
896 )
897 .unwrap();
898 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
899
900 let mut cfg = test_config();
901 cfg.skills_dir = dir.path().to_path_buf();
902 cfg.workspace_dir = Some(ws_dir.path().to_path_buf());
903 let runner = ScriptRunner::new(cfg, test_fs_security());
904 let result = runner
905 .execute(Path::new("check_ws.sh"), &[])
906 .await
907 .expect("script should execute");
908
909 assert_eq!(result.exit_code, 0);
910 assert!(
911 result
912 .stdout
913 .contains(&format!("SKILLS={}", dir.path().display())),
914 "ROBOTICUS_SKILLS_DIR not set, got: {}",
915 result.stdout
916 );
917 assert!(
918 result
919 .stdout
920 .contains(&format!("WS={}", ws_dir.path().display())),
921 "ROBOTICUS_WORKSPACE not set, got: {}",
922 result.stdout
923 );
924 }
925
926 #[tokio::test]
927 async fn sandbox_env_keeps_minimal_runtime_vars_only() {
928 let _g1 = EnvGuard::set("SECRET_TOKEN", "definitely-secret");
929 let _g2 = EnvGuard::set("LANG", "en_US.UTF-8");
930
931 let dir = tempfile::tempdir().unwrap();
932 let script = dir.path().join("print_env_subset.sh");
933 fs::write(
934 &script,
935 "#!/bin/bash\nprintf \"PATH=%s\\nHOME=%s\\nTMP=%s\\nLANG=%s\\nTOKEN=%s\" \"${PATH:-}\" \"${HOME:-}\" \"${TMP:-}\" \"${LANG:-}\" \"${SECRET_TOKEN:-MISSING}\"",
936 )
937 .unwrap();
938 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
939
940 let mut cfg = test_config();
941 cfg.sandbox_env = true;
942 cfg.skills_dir = dir.path().to_path_buf();
943 let runner = ScriptRunner::new(cfg, test_fs_security());
944 let result = runner
945 .execute(Path::new("print_env_subset.sh"), &[])
946 .await
947 .expect("script should execute");
948
949 assert_eq!(result.exit_code, 0);
950 assert!(result.stdout.contains("PATH="));
951 assert!(result.stdout.contains("HOME="));
952 assert!(result.stdout.contains("TMP="));
953 assert!(result.stdout.contains("LANG=en_US.UTF-8"));
954 assert!(
955 result.stdout.ends_with("TOKEN=MISSING"),
956 "non-allowlisted secrets must not be present"
957 );
958 }
959
960 #[cfg(target_os = "macos")]
961 #[test]
962 fn sandbox_profile_contains_expected_rules() {
963 use std::io::Read;
964
965 let skills = tempfile::tempdir().unwrap();
966 let workspace = tempfile::tempdir().unwrap();
967 let extra = tempfile::tempdir().unwrap();
968
969 let profile = generate_sandbox_profile(
970 skills.path(),
971 Some(workspace.path()),
972 &[extra.path().to_path_buf()],
973 false,
974 )
975 .unwrap();
976
977 let mut contents = String::new();
978 std::fs::File::open(profile.path())
979 .unwrap()
980 .read_to_string(&mut contents)
981 .unwrap();
982
983 assert!(contents.contains("(version 1)"), "missing version");
984 assert!(contents.contains("(deny default)"), "missing deny default");
985
986 assert!(
988 contents.contains("(allow file-read*)"),
989 "should allow global reads: {contents}"
990 );
991
992 let workspace_canon = workspace.path().canonicalize().unwrap();
994 let extra_canon = extra.path().canonicalize().unwrap();
995 assert!(
996 contents.contains(&format!(
997 "(allow file-write* (subpath \"{}\"))",
998 workspace_canon.display()
999 )),
1000 "workspace_dir not in write rules: {contents}"
1001 );
1002 assert!(
1003 contents.contains(&format!(
1004 "(allow file-write* (subpath \"{}\"))",
1005 extra_canon.display()
1006 )),
1007 "extra path not in write rules: {contents}"
1008 );
1009
1010 assert!(
1012 contents.contains("(allow file-write* (subpath \"/tmp\"))"),
1013 "/tmp not writable: {contents}"
1014 );
1015
1016 assert!(
1018 !contents.contains("(allow network"),
1019 "network should be denied"
1020 );
1021 assert!(
1022 contents.contains("Network denied"),
1023 "should note network denial"
1024 );
1025 }
1026
1027 #[cfg(target_os = "macos")]
1028 #[test]
1029 fn sandbox_profile_allows_network_when_configured() {
1030 use std::io::Read;
1031
1032 let skills = tempfile::tempdir().unwrap();
1033 let profile = generate_sandbox_profile(skills.path(), None, &[], true).unwrap();
1034
1035 let mut contents = String::new();
1036 std::fs::File::open(profile.path())
1037 .unwrap()
1038 .read_to_string(&mut contents)
1039 .unwrap();
1040
1041 assert!(
1042 contents.contains("(allow network*)"),
1043 "network should be allowed when network_allowed=true"
1044 );
1045 }
1046
1047 #[cfg(target_os = "macos")]
1048 #[tokio::test]
1049 async fn sandbox_exec_confines_script_filesystem() {
1050 let skills_dir = tempfile::tempdir().unwrap();
1054 let forbidden_dir = tempfile::tempdir().unwrap();
1055 let forbidden_file = forbidden_dir.path().join("should_not_exist.txt");
1056
1057 let script = skills_dir.path().join("write_outside.sh");
1058 fs::write(
1059 &script,
1060 format!(
1061 "#!/bin/bash\necho 'breach' > '{}' 2>/dev/null && echo WRITTEN || echo BLOCKED",
1062 forbidden_file.display()
1063 ),
1064 )
1065 .unwrap();
1066 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1067
1068 let mut cfg = test_config();
1069 cfg.skills_dir = skills_dir.path().to_path_buf();
1070 cfg.sandbox_env = true;
1071
1072 let fs_sec = FilesystemSecurityConfig {
1073 script_fs_confinement: true,
1074 ..Default::default()
1075 };
1076
1077 let runner = ScriptRunner::new(cfg, fs_sec);
1078 let result = runner
1079 .execute(Path::new("write_outside.sh"), &[])
1080 .await
1081 .unwrap();
1082
1083 assert!(
1084 result.stdout.contains("BLOCKED"),
1085 "sandbox should block writes outside allowed paths, stdout={:?} stderr={:?} exit={}",
1086 result.stdout,
1087 result.stderr,
1088 result.exit_code
1089 );
1090 assert!(
1091 !forbidden_file.exists(),
1092 "file should not have been created outside sandbox"
1093 );
1094 }
1095}