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 _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)]
512#[cfg(unix)]
513mod tests {
514 use super::*;
515 use crate::test_support::EnvGuard;
516 use std::fs;
517 use std::os::unix::fs::PermissionsExt;
518
519 fn test_config() -> SkillsConfig {
520 SkillsConfig {
521 script_timeout_seconds: 5,
522 script_max_output_bytes: 1024,
523 allowed_interpreters: vec!["bash".into(), "python3".into(), "node".into()],
524 sandbox_env: true,
525 ..Default::default()
526 }
527 }
528
529 fn test_fs_security() -> FilesystemSecurityConfig {
530 FilesystemSecurityConfig {
531 script_fs_confinement: false,
534 ..Default::default()
535 }
536 }
537
538 #[tokio::test]
539 async fn successful_script_execution() {
540 let dir = tempfile::tempdir().unwrap();
541 let script = dir.path().join("test.sh");
542 fs::write(&script, "#!/bin/bash\necho \"hello from script\"").unwrap();
543 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
544
545 let mut cfg = test_config();
546 cfg.skills_dir = dir.path().to_path_buf();
547 let runner = ScriptRunner::new(cfg, test_fs_security());
548 let result = runner.execute(Path::new("test.sh"), &[]).await.unwrap();
549
550 assert_eq!(result.exit_code, 0);
551 assert!(result.stdout.contains("hello from script"));
552 }
553
554 #[test]
555 fn interpreter_whitelist_rejection() {
556 let dir = tempfile::tempdir().unwrap();
557 let script = dir.path().join("evil.rb");
558 fs::write(&script, "#!/usr/bin/ruby\nputs 'hi'").unwrap();
559
560 let allowed = vec!["bash".into(), "python3".into()];
561 let result = check_interpreter(&script, &allowed);
562 assert!(result.is_err());
563 let err_msg = result.unwrap_err().to_string();
564 assert!(err_msg.contains("not in whitelist"));
565 }
566
567 #[tokio::test]
568 async fn timeout_handling() {
569 let dir = tempfile::tempdir().unwrap();
570 let script = dir.path().join("slow.sh");
571 fs::write(&script, "#!/bin/bash\nsleep 60").unwrap();
572 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
573
574 let mut config = test_config();
575 config.script_timeout_seconds = 1;
576 config.skills_dir = dir.path().to_path_buf();
577
578 let runner = ScriptRunner::new(config, test_fs_security());
579 let result = runner.execute(Path::new("slow.sh"), &[]).await;
580
581 assert!(result.is_err());
582 let err_msg = result.unwrap_err().to_string();
583 assert!(err_msg.contains("timed out"));
584 }
585
586 #[tokio::test]
587 async fn rejects_absolute_script_path() {
588 let skills_dir = tempfile::tempdir().unwrap();
589 let outside_dir = tempfile::tempdir().unwrap();
590 let script = outside_dir.path().join("escape.sh");
591 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
592 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
593
594 let mut cfg = test_config();
595 cfg.skills_dir = skills_dir.path().to_path_buf();
596
597 let runner = ScriptRunner::new(cfg, test_fs_security());
598 let result = runner.execute(&script, &[]).await;
599 assert!(result.is_err());
600 let msg = result.unwrap_err().to_string();
601 assert!(msg.contains("absolute script paths are not allowed"));
602 }
603
604 #[test]
605 fn infer_interpreter_from_extension() {
606 let dir = tempfile::tempdir().unwrap();
607
608 let py_script = dir.path().join("test.py");
609 fs::write(&py_script, "print('hi')").unwrap();
610
611 #[cfg(windows)]
612 let allowed = vec![
613 "bash".to_string(),
614 "python".to_string(),
615 "python3".to_string(),
616 "node".to_string(),
617 ];
618 #[cfg(not(windows))]
619 let allowed = vec![
620 "bash".to_string(),
621 "python3".to_string(),
622 "node".to_string(),
623 ];
624
625 let py_result = check_interpreter(&py_script, &allowed).unwrap();
628 #[cfg(windows)]
629 assert!(py_result.ends_with("python") || py_result.ends_with("python.exe"));
630 #[cfg(not(windows))]
631 assert!(
632 Path::new(&py_result).is_absolute() && py_result.contains("python"),
633 "expected absolute python path, got: {py_result}"
634 );
635
636 let sh_script = dir.path().join("test.sh");
637 fs::write(&sh_script, "echo hi").unwrap();
638 let sh_result = check_interpreter(&sh_script, &allowed).unwrap();
639 assert!(
640 sh_result.ends_with("/bash"),
641 "expected absolute bash path, got: {sh_result}"
642 );
643
644 let js_script = dir.path().join("test.js");
645 fs::write(&js_script, "console.log('hi')").unwrap();
646 let js_result = check_interpreter(&js_script, &allowed).unwrap();
647 assert!(
648 js_result.ends_with("/node"),
649 "expected absolute node path, got: {js_result}"
650 );
651 }
652
653 #[test]
654 fn check_interpreter_env_shebang() {
655 let dir = tempfile::tempdir().unwrap();
658 let script = dir.path().join("env_shebang.py");
659 fs::write(&script, "#!/usr/bin/env python3\nprint('hi')").unwrap();
660 let allowed = vec!["python3".to_string()];
661 let interp = check_interpreter(&script, &allowed).unwrap();
662 assert!(
663 Path::new(&interp).is_absolute() && interp.contains("python"),
664 "expected absolute python path, got: {interp}"
665 );
666 }
667
668 #[test]
669 fn check_interpreter_env_shebang_not_allowed() {
670 let dir = tempfile::tempdir().unwrap();
671 let script = dir.path().join("env_ruby.rb");
672 fs::write(&script, "#!/usr/bin/env ruby\nputs 'hi'").unwrap();
673 let allowed = vec!["python3".to_string(), "bash".to_string()];
674 let result = check_interpreter(&script, &allowed);
675 assert!(result.is_err());
676 assert!(result.unwrap_err().to_string().contains("not in whitelist"));
677 }
678
679 #[test]
680 fn check_interpreter_unknown_extension() {
681 let dir = tempfile::tempdir().unwrap();
682 let script = dir.path().join("test.xyz");
683 fs::write(&script, "some content").unwrap();
684 let allowed = vec!["bash".to_string()];
685 let result = check_interpreter(&script, &allowed);
686 assert!(result.is_err());
687 assert!(
688 result
689 .unwrap_err()
690 .to_string()
691 .contains("cannot infer interpreter")
692 );
693 }
694
695 #[test]
696 fn check_interpreter_bash_extension() {
697 let dir = tempfile::tempdir().unwrap();
698 let script = dir.path().join("test.bash");
699 fs::write(&script, "echo hi").unwrap();
700 let allowed = vec!["bash".to_string()];
701 let interp = check_interpreter(&script, &allowed).unwrap();
702 assert!(
703 interp.ends_with("/bash"),
704 "expected absolute bash path, got: {interp}"
705 );
706 }
707
708 #[test]
709 fn world_writable_script_rejected() {
710 let dir = tempfile::tempdir().unwrap();
711 let script = dir.path().join("writable.sh");
712 fs::write(&script, "#!/bin/bash\necho hi").unwrap();
713 fs::set_permissions(&script, fs::Permissions::from_mode(0o777)).unwrap();
714
715 let mut cfg = test_config();
716 cfg.skills_dir = dir.path().to_path_buf();
717 let runner = ScriptRunner::new(cfg, test_fs_security());
718 let result = runner.resolve_script_path(Path::new("writable.sh"));
719 assert!(result.is_err());
720 assert!(result.unwrap_err().to_string().contains("world-writable"));
721 }
722
723 #[test]
724 fn resolve_rejects_directory_traversal() {
725 let dir = tempfile::tempdir().unwrap();
726 let mut cfg = test_config();
727 cfg.skills_dir = dir.path().to_path_buf();
728 let runner = ScriptRunner::new(cfg, test_fs_security());
729
730 let result = runner.resolve_script_path(Path::new("../../etc/passwd"));
732 assert!(result.is_err());
733 }
734
735 #[test]
736 fn resolve_rejects_absolute_path() {
737 let dir = tempfile::tempdir().unwrap();
738 let mut cfg = test_config();
739 cfg.skills_dir = dir.path().to_path_buf();
740 let runner = ScriptRunner::new(cfg, test_fs_security());
741
742 let result = runner.resolve_script_path(Path::new("/etc/passwd"));
743 assert!(result.is_err());
744 assert!(
745 result
746 .unwrap_err()
747 .to_string()
748 .contains("absolute script paths")
749 );
750 }
751
752 #[test]
753 fn truncate_str_within_limit() {
754 let s = "hello world";
755 assert_eq!(truncate_str(s, 100), "hello world");
756 }
757
758 #[test]
759 fn truncate_str_at_limit() {
760 let s = "hello";
761 assert_eq!(truncate_str(s, 5), "hello");
762 }
763
764 #[test]
765 fn truncate_str_beyond_limit() {
766 let s = "hello world";
767 let truncated = truncate_str(s, 5);
768 assert_eq!(truncated, "hello");
769 }
770
771 #[test]
772 fn truncate_str_multibyte_boundary() {
773 let s = "café";
775 let truncated = truncate_str(s, 4);
776 assert_eq!(truncated, "caf");
779 }
780
781 #[tokio::test]
782 async fn script_with_args() {
783 let dir = tempfile::tempdir().unwrap();
784 let script = dir.path().join("args.sh");
785 fs::write(&script, "#!/bin/bash\necho \"$1 $2\"").unwrap();
786 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
787
788 let mut cfg = test_config();
789 cfg.skills_dir = dir.path().to_path_buf();
790 let runner = ScriptRunner::new(cfg, test_fs_security());
791 let result = runner
792 .execute(Path::new("args.sh"), &["hello", "world"])
793 .await
794 .unwrap();
795
796 assert_eq!(result.exit_code, 0);
797 assert!(result.stdout.contains("hello world"));
798 }
799
800 #[tokio::test]
801 async fn script_nonzero_exit_code() {
802 let dir = tempfile::tempdir().unwrap();
803 let script = dir.path().join("fail.sh");
804 fs::write(&script, "#!/bin/bash\nexit 42").unwrap();
805 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
806
807 let mut cfg = test_config();
808 cfg.skills_dir = dir.path().to_path_buf();
809 let runner = ScriptRunner::new(cfg, test_fs_security());
810 let result = runner.execute(Path::new("fail.sh"), &[]).await.unwrap();
811
812 assert_eq!(result.exit_code, 42);
813 }
814
815 #[tokio::test]
816 async fn script_output_truncation() {
817 let dir = tempfile::tempdir().unwrap();
818 let script = dir.path().join("verbose.sh");
819 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();
821 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
822
823 let mut cfg = test_config();
824 cfg.skills_dir = dir.path().to_path_buf();
825 let runner = ScriptRunner::new(cfg, test_fs_security());
826 let result = runner.execute(Path::new("verbose.sh"), &[]).await.unwrap();
827
828 assert!(
829 result.stdout.len() <= 1024,
830 "stdout should be truncated to max_output_bytes"
831 );
832 }
833
834 #[tokio::test]
835 async fn sandbox_env_strips_secrets() {
836 let _guard = EnvGuard::set("OPENAI_API_KEY", "top-secret-test-value");
837
838 let dir = tempfile::tempdir().unwrap();
839 let script = dir.path().join("print_secret.sh");
840 fs::write(
841 &script,
842 "#!/bin/bash\nprintf \"%s\" \"${OPENAI_API_KEY:-MISSING}\"",
843 )
844 .unwrap();
845 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
846
847 let mut cfg = test_config();
848 cfg.sandbox_env = true;
849 cfg.skills_dir = dir.path().to_path_buf();
850 let runner = ScriptRunner::new(cfg, test_fs_security());
851 let result = runner
852 .execute(Path::new("print_secret.sh"), &[])
853 .await
854 .expect("script should execute");
855
856 assert_eq!(result.exit_code, 0);
857 assert_eq!(
858 result.stdout.trim(),
859 "MISSING",
860 "sandboxed script must not inherit secret env vars"
861 );
862 }
863
864 #[test]
865 fn resolve_interpreter_absolute_finds_bash() {
866 let abs = resolve_interpreter_absolute("bash").unwrap();
867 assert!(
868 Path::new(&abs).is_absolute(),
869 "expected absolute path, got: {abs}"
870 );
871 assert!(
872 abs.ends_with("/bash"),
873 "expected path ending in /bash, got: {abs}"
874 );
875 }
876
877 #[test]
878 fn resolve_interpreter_absolute_rejects_missing() {
879 let result = resolve_interpreter_absolute("nonexistent_binary_xyz_123");
880 assert!(result.is_err());
881 assert!(
882 result
883 .unwrap_err()
884 .to_string()
885 .contains("not found in PATH")
886 );
887 }
888
889 #[tokio::test]
890 async fn sandbox_exposes_workspace_env_vars() {
891 let dir = tempfile::tempdir().unwrap();
892 let ws_dir = tempfile::tempdir().unwrap();
893 let script = dir.path().join("check_ws.sh");
894 fs::write(
895 &script,
896 "#!/bin/bash\nprintf \"SKILLS=%s WS=%s\" \"${ROBOTICUS_SKILLS_DIR:-MISSING}\" \"${ROBOTICUS_WORKSPACE:-MISSING}\"",
897 )
898 .unwrap();
899 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
900
901 let mut cfg = test_config();
902 cfg.skills_dir = dir.path().to_path_buf();
903 cfg.workspace_dir = Some(ws_dir.path().to_path_buf());
904 let runner = ScriptRunner::new(cfg, test_fs_security());
905 let result = runner
906 .execute(Path::new("check_ws.sh"), &[])
907 .await
908 .expect("script should execute");
909
910 assert_eq!(result.exit_code, 0);
911 assert!(
912 result
913 .stdout
914 .contains(&format!("SKILLS={}", dir.path().display())),
915 "ROBOTICUS_SKILLS_DIR not set, got: {}",
916 result.stdout
917 );
918 assert!(
919 result
920 .stdout
921 .contains(&format!("WS={}", ws_dir.path().display())),
922 "ROBOTICUS_WORKSPACE not set, got: {}",
923 result.stdout
924 );
925 }
926
927 #[tokio::test]
928 async fn sandbox_env_keeps_minimal_runtime_vars_only() {
929 let _g1 = EnvGuard::set("SECRET_TOKEN", "definitely-secret");
930 let _g2 = EnvGuard::set("LANG", "en_US.UTF-8");
931
932 let dir = tempfile::tempdir().unwrap();
933 let script = dir.path().join("print_env_subset.sh");
934 fs::write(
935 &script,
936 "#!/bin/bash\nprintf \"PATH=%s\\nHOME=%s\\nTMP=%s\\nLANG=%s\\nTOKEN=%s\" \"${PATH:-}\" \"${HOME:-}\" \"${TMP:-}\" \"${LANG:-}\" \"${SECRET_TOKEN:-MISSING}\"",
937 )
938 .unwrap();
939 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
940
941 let mut cfg = test_config();
942 cfg.sandbox_env = true;
943 cfg.skills_dir = dir.path().to_path_buf();
944 let runner = ScriptRunner::new(cfg, test_fs_security());
945 let result = runner
946 .execute(Path::new("print_env_subset.sh"), &[])
947 .await
948 .expect("script should execute");
949
950 assert_eq!(result.exit_code, 0);
951 assert!(result.stdout.contains("PATH="));
952 assert!(result.stdout.contains("HOME="));
953 assert!(result.stdout.contains("TMP="));
954 assert!(result.stdout.contains("LANG=en_US.UTF-8"));
955 assert!(
956 result.stdout.ends_with("TOKEN=MISSING"),
957 "non-allowlisted secrets must not be present"
958 );
959 }
960
961 #[cfg(target_os = "macos")]
962 #[test]
963 fn sandbox_profile_contains_expected_rules() {
964 use std::io::Read;
965
966 let skills = tempfile::tempdir().unwrap();
967 let workspace = tempfile::tempdir().unwrap();
968 let extra = tempfile::tempdir().unwrap();
969
970 let profile = generate_sandbox_profile(
971 skills.path(),
972 Some(workspace.path()),
973 &[extra.path().to_path_buf()],
974 false,
975 )
976 .unwrap();
977
978 let mut contents = String::new();
979 std::fs::File::open(profile.path())
980 .unwrap()
981 .read_to_string(&mut contents)
982 .unwrap();
983
984 assert!(contents.contains("(version 1)"), "missing version");
985 assert!(contents.contains("(deny default)"), "missing deny default");
986
987 assert!(
989 contents.contains("(allow file-read*)"),
990 "should allow global reads: {contents}"
991 );
992
993 let workspace_canon = workspace.path().canonicalize().unwrap();
995 let extra_canon = extra.path().canonicalize().unwrap();
996 assert!(
997 contents.contains(&format!(
998 "(allow file-write* (subpath \"{}\"))",
999 workspace_canon.display()
1000 )),
1001 "workspace_dir not in write rules: {contents}"
1002 );
1003 assert!(
1004 contents.contains(&format!(
1005 "(allow file-write* (subpath \"{}\"))",
1006 extra_canon.display()
1007 )),
1008 "extra path not in write rules: {contents}"
1009 );
1010
1011 assert!(
1013 contents.contains("(allow file-write* (subpath \"/tmp\"))"),
1014 "/tmp not writable: {contents}"
1015 );
1016
1017 assert!(
1019 !contents.contains("(allow network"),
1020 "network should be denied"
1021 );
1022 assert!(
1023 contents.contains("Network denied"),
1024 "should note network denial"
1025 );
1026 }
1027
1028 #[cfg(target_os = "macos")]
1029 #[test]
1030 fn sandbox_profile_allows_network_when_configured() {
1031 use std::io::Read;
1032
1033 let skills = tempfile::tempdir().unwrap();
1034 let profile = generate_sandbox_profile(skills.path(), None, &[], true).unwrap();
1035
1036 let mut contents = String::new();
1037 std::fs::File::open(profile.path())
1038 .unwrap()
1039 .read_to_string(&mut contents)
1040 .unwrap();
1041
1042 assert!(
1043 contents.contains("(allow network*)"),
1044 "network should be allowed when network_allowed=true"
1045 );
1046 }
1047
1048 #[cfg(target_os = "macos")]
1049 #[tokio::test]
1050 async fn sandbox_exec_confines_script_filesystem() {
1051 let skills_dir = tempfile::tempdir().unwrap();
1055 let forbidden_dir = tempfile::tempdir().unwrap();
1056 let forbidden_file = forbidden_dir.path().join("should_not_exist.txt");
1057
1058 let script = skills_dir.path().join("write_outside.sh");
1059 fs::write(
1060 &script,
1061 format!(
1062 "#!/bin/bash\necho 'breach' > '{}' 2>/dev/null && echo WRITTEN || echo BLOCKED",
1063 forbidden_file.display()
1064 ),
1065 )
1066 .unwrap();
1067 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1068
1069 let mut cfg = test_config();
1070 cfg.skills_dir = skills_dir.path().to_path_buf();
1071 cfg.sandbox_env = true;
1072
1073 let fs_sec = FilesystemSecurityConfig {
1074 script_fs_confinement: true,
1075 ..Default::default()
1076 };
1077
1078 let runner = ScriptRunner::new(cfg, fs_sec);
1079 let result = runner
1080 .execute(Path::new("write_outside.sh"), &[])
1081 .await
1082 .unwrap();
1083
1084 if result.exit_code == 71
1085 && result
1086 .stderr
1087 .contains("sandbox_apply: Operation not permitted")
1088 {
1089 return;
1090 }
1091
1092 assert!(
1093 result.stdout.contains("BLOCKED"),
1094 "sandbox should block writes outside allowed paths, stdout={:?} stderr={:?} exit={}",
1095 result.stdout,
1096 result.stderr,
1097 result.exit_code
1098 );
1099 assert!(
1100 !forbidden_file.exists(),
1101 "file should not have been created outside sandbox"
1102 );
1103 }
1104}