Skip to main content

roboticus_agent/
script_runner.rs

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    // Used in the macOS sandbox-exec path (`#[cfg(target_os = "macos")]`).
19    #[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        // ── Build command, optionally wrapping with macOS sandbox-exec ───
38        // The _sandbox_profile guard keeps the tempfile alive until the child
39        // process finishes; sandbox-exec reads the profile at exec time.
40        #[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            // Expose the skills directory and optional workspace root so scripts
91            // know their boundaries without guessing.
92            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        // Pre-exec resource limits (Unix only).
99        #[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            // SAFETY: pre_exec runs in the forked child before exec.
104            // Only async-signal-safe functions are called (setrlimit, unshare).
105            unsafe {
106                cmd.pre_exec(move || {
107                    // Memory ceiling via RLIMIT_AS on Linux.
108                    // macOS virtual memory model makes RLIMIT_AS unreliable
109                    // (processes routinely map far more virtual space than
110                    // they physically use), so we skip enforcement there.
111                    #[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                    // Network isolation via unshare(CLONE_NEWNET) on Linux.
124                    #[cfg(target_os = "linux")]
125                    if deny_net && libc::unshare(libc::CLONE_NEWNET) != 0 {
126                        // Non-fatal: user namespaces may be disabled.
127                        // The mechanic health check will warn about this.
128                        eprintln!(
129                            "roboticus: warning: network isolation unavailable (unshare failed)"
130                        );
131                    }
132                    // On macOS there is no unprivileged network namespace API.
133                    // The mechanic health check notes this platform limitation.
134                    #[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    /// Resolve a requested script path under the configured skills root.
213    ///
214    /// This canonicalizes both root and script path and enforces containment.
215    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/// Generate a macOS `sandbox-exec` profile (.sb) that confines script
277/// filesystem access to known-good paths.
278///
279/// The profile uses a deny-default posture and selectively allows:
280/// - Process execution (interpreters under `/usr`, `/opt`, Homebrew, Nix)
281/// - System library reads (frameworks, dyld cache)
282/// - `skills_dir` (read-only)
283/// - `workspace_dir` (read-write, if configured)
284/// - `/tmp` (read-write, for scratch files)
285/// - `script_allowed_paths` (read-only)
286/// - Network (only if `network_allowed` is true)
287#[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    // Canonicalize paths — macOS sandbox-exec resolves symlinks internally
297    // (e.g. /var → /private/var), so profile paths must match the resolved
298    // form. Fall back to the original path if canonicalization fails.
299    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    // Apple Sandbox Profile Language (SBPL).
312    // Reference: TN3145 (Apple), reverse-engineered from system profiles.
313    //
314    // Strategy: **write-denial model** — allow reads globally, restrict writes
315    // to specific paths. Interpreters (bash, python, node, ruby) probe many
316    // unpredictable paths at startup (dyld cache, locale, Homebrew, nix, etc.)
317    // making a read-whitelist fragile across macOS versions. The security value
318    // is in preventing *writes* outside the workspace/tmp sandbox; read access
319    // is already scoped by the OS user's filesystem permissions.
320    let mut sb = String::with_capacity(2048);
321    sb.push_str("(version 1)\n");
322    sb.push_str("(deny default)\n\n");
323
324    // ── Process execution ────────────────────────────────────────
325    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    // ── Read access (global) ─────────────────────────────────────
330    // Interpreters need to read system libraries, frameworks, language
331    // runtimes, and config in unpredictable locations. Grant broad read.
332    sb.push_str("; Global read access — writes are the confinement boundary\n");
333    sb.push_str("(allow file-read*)\n\n");
334
335    // ── Write access (confined) ──────────────────────────────────
336    // Only allow writes to: /dev/null, /tmp, workspace, and skills_dir.
337    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    // Workspace directory (read-write, if configured)
345    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    // Extra allowed paths — write access (user-configured escape hatches)
354    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    // ── IPC / mach / signals ─────────────────────────────────────
362    // Language runtimes (Python, Node) need these for normal operation.
363    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    // ── Network ──────────────────────────────────────────────────
371    // On Linux, network isolation uses unshare(CLONE_NEWNET).
372    // On macOS, sandbox-exec handles it natively via the profile.
373    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
419/// Resolve a bare interpreter name to its canonical absolute path by walking PATH.
420///
421/// If the name is already absolute, canonicalize and return it.
422/// This prevents PATH-hijacking attacks where a malicious binary shadows
423/// a legitimate interpreter earlier in the search order.
424pub 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
448/// Determines the interpreter for a script by reading its shebang line
449/// or inferring from the file extension, then checks against the whitelist.
450/// Returns the **absolute path** to the interpreter to prevent PATH hijacking.
451pub 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            // Disable sandbox-exec in tests by default to avoid requiring
532            // /usr/bin/sandbox-exec and to keep tests fast and isolated.
533            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        // check_interpreter now returns absolute paths; verify it's an absolute python path.
626        // Canonical resolution may follow symlinks (e.g. python3 → python3.14 on Homebrew).
627        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        // #!/usr/bin/env python3 -> should resolve to absolute python path
656        // (canonical may resolve symlink, e.g. python3 → python3.14 on Homebrew)
657        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        // Attempting to escape skills_dir with ../
731        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        // "é" is 2 bytes in UTF-8; truncating at odd boundary should back up
774        let s = "café";
775        let truncated = truncate_str(s, 4);
776        // "caf" is 3 bytes, "é" is 2 bytes (bytes 3-4)
777        // truncating at 4 lands in the middle of é, should back up to 3
778        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        // Generate output > max_output_bytes (set to 1024 in test_config)
820        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        // Write-denial model: reads are global, writes confined to specific paths.
988        assert!(
989            contents.contains("(allow file-read*)"),
990            "should allow global reads: {contents}"
991        );
992
993        // Workspace and extra paths get file-write* rules (canonicalized).
994        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        // /tmp writable
1012        assert!(
1013            contents.contains("(allow file-write* (subpath \"/tmp\"))"),
1014            "/tmp not writable: {contents}"
1015        );
1016
1017        // Network denied when network_allowed=false
1018        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        // This test verifies that sandbox-exec actually blocks writes outside
1052        // allowed paths. It creates a script that tries to write to a path
1053        // outside the sandbox and asserts the write fails.
1054        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}