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