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::{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    // 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)]
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            // Disable sandbox-exec in tests by default to avoid requiring
531            // /usr/bin/sandbox-exec and to keep tests fast and isolated.
532            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        // check_interpreter now returns absolute paths; verify it's an absolute python path.
625        // Canonical resolution may follow symlinks (e.g. python3 → python3.14 on Homebrew).
626        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        // #!/usr/bin/env python3 -> should resolve to absolute python path
655        // (canonical may resolve symlink, e.g. python3 → python3.14 on Homebrew)
656        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        // Attempting to escape skills_dir with ../
730        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        // "é" is 2 bytes in UTF-8; truncating at odd boundary should back up
773        let s = "café";
774        let truncated = truncate_str(s, 4);
775        // "caf" is 3 bytes, "é" is 2 bytes (bytes 3-4)
776        // truncating at 4 lands in the middle of é, should back up to 3
777        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        // Generate output > max_output_bytes (set to 1024 in test_config)
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();
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        // Write-denial model: reads are global, writes confined to specific paths.
987        assert!(
988            contents.contains("(allow file-read*)"),
989            "should allow global reads: {contents}"
990        );
991
992        // Workspace and extra paths get file-write* rules (canonicalized).
993        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        // /tmp writable
1011        assert!(
1012            contents.contains("(allow file-write* (subpath \"/tmp\"))"),
1013            "/tmp not writable: {contents}"
1014        );
1015
1016        // Network denied when network_allowed=false
1017        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        // This test verifies that sandbox-exec actually blocks writes outside
1051        // allowed paths. It creates a script that tries to write to a path
1052        // outside the sandbox and asserts the write fails.
1053        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}