Skip to main content

astrid_workspace/sandbox/
mod.rs

1use std::ffi::OsString;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[cfg(target_os = "linux")]
7mod bwrap;
8#[cfg(target_os = "macos")]
9mod seatbelt;
10
11/// Validate a path for safe interpolation into sandbox profiles (SBPL/bwrap).
12///
13/// Rejects relative paths, non-UTF-8, double-quote, backslash, and null byte -
14/// all of which can break or bypass sandbox profile syntax.
15fn validate_sandbox_str<'a>(path: &'a Path, label: &str) -> io::Result<&'a str> {
16    if !path.is_absolute() {
17        return Err(io::Error::new(
18            io::ErrorKind::InvalidInput,
19            format!(
20                "sandbox {label} must be an absolute path, got: {}",
21                path.display()
22            ),
23        ));
24    }
25    let s = path.to_str().ok_or_else(|| {
26        io::Error::new(
27            io::ErrorKind::InvalidInput,
28            format!("sandbox {label} is not valid UTF-8: {}", path.display()),
29        )
30    })?;
31    if s.contains(['"', '\\', '\0']) {
32        return Err(io::Error::new(
33            io::ErrorKind::InvalidInput,
34            format!(
35                "sandbox {label} contains forbidden characters (double-quote, backslash, or null): {}",
36                path.display()
37            ),
38        ));
39    }
40    Ok(s)
41}
42
43/// Wraps a standard OS command in a native kernel sandbox (bwrap or Seatbelt).
44///
45/// Ensures that agent-executed native tools are restricted from accessing
46/// anything outside the provided worktree sandbox.
47pub struct SandboxCommand;
48
49impl SandboxCommand {
50    /// Wraps the provided command in the host OS sandbox, restricting its access to
51    /// the provided `worktree_path`.
52    ///
53    /// - On Linux, this dynamically prepends `bwrap` with strict mount rules.
54    /// - On macOS, this dynamically generates a Seatbelt profile and prepends `sandbox-exec -p`.
55    /// - On other platforms (Windows), this currently passes through the command unmodified (with a warning).
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the worktree path is not absolute, not valid UTF-8,
60    /// or contains characters unsafe for SBPL interpolation (double-quote,
61    /// backslash, or null byte).
62    ///
63    /// # Panics
64    ///
65    /// Panics on macOS if `validate_sandbox_str` passes but the path is not
66    /// valid UTF-8. This is unreachable because the validation rejects
67    /// non-UTF-8 paths.
68    #[allow(clippy::needless_pass_by_value)] // Consumed on macOS early return, borrowed on Linux bwrap
69    pub fn wrap(inner_cmd: Command, worktree_path: &Path) -> io::Result<Command> {
70        // Validate on all platforms for defense in depth and API consistency.
71        // On macOS the validated string is needed for SBPL interpolation.
72        // On Linux bwrap passes paths as argv entries (no injection risk),
73        // but we still reject unsafe paths at the API boundary.
74        let _ = validate_sandbox_str(worktree_path, "worktree path")?;
75
76        #[cfg(target_os = "linux")]
77        {
78            // Bubblewrap implementation - paths are passed as separate argv entries (no injection).
79            // The process can only read the root OS, but can only write to the worktree and /tmp.
80            let mut bwrap = Command::new("bwrap");
81            bwrap
82                .arg("--ro-bind").arg("/").arg("/") // Read-only access to host OS (for binaries like /usr/bin/node)
83                .arg("--dev").arg("/dev")           // Standard dev mounts
84                .arg("--proc").arg("/proc")         // Standard proc mounts
85                .arg("--bind").arg(worktree_path).arg(worktree_path) // Write access to the worktree
86                .arg("--tmpfs").arg("/tmp")         // Disposable tmpfs
87                .arg("--unshare-all")               // Drop namespaces (network, pid, etc.)
88                .arg("--share-net")                 // Re-enable network so npm/cargo can fetch
89                .arg("--die-with-parent"); // Prevent orphan processes
90
91            // Extract the original command and args, and append them to bwrap
92            bwrap.arg(inner_cmd.get_program());
93            for arg in inner_cmd.get_args() {
94                bwrap.arg(arg);
95            }
96
97            // Inherit the env and current_dir from the original command
98            for (k, v) in inner_cmd.get_envs() {
99                if let Some(v) = v {
100                    bwrap.env(k, v);
101                } else {
102                    bwrap.env_remove(k);
103                }
104            }
105            if let Some(dir) = inner_cmd.get_current_dir() {
106                bwrap.current_dir(dir);
107            }
108
109            Ok(bwrap)
110        }
111
112        #[cfg(target_os = "macos")]
113        {
114            // sandbox-exec (Seatbelt) is deprecated on macOS 15+ (Darwin >= 24).
115            if seatbelt::darwin_major_version() >= 24 {
116                tracing::warn!(
117                    "macOS 15+ detected: sandbox-exec is deprecated. Running host process unsandboxed."
118                );
119                return Ok(inner_cmd);
120            }
121
122            // Safe: validate_sandbox_str above confirmed valid UTF-8.
123            let worktree_str = worktree_path
124                .to_str()
125                .expect("unreachable: validated UTF-8 above");
126
127            // macOS Seatbelt implementation
128            // Deny all writes except to the worktree and /tmp.
129            // Restrict reads to system directories, the worktree, and tmp to protect user dotfiles.
130            let profile = format!(
131                r#"(version 1)
132(deny default)
133(allow process-exec*)
134(allow process-fork)
135(allow network*)
136(allow sysctl-read)
137(allow ipc-posix-shm)
138(allow file-read*
139    (subpath "/usr")
140    (subpath "/bin")
141    (subpath "/sbin")
142    (subpath "/System")
143    (subpath "/Library")
144    (subpath "/opt")
145    (subpath "/dev")
146    (subpath "{worktree_str}")
147    (subpath "/private/tmp")
148    (subpath "/var/folders")
149)
150(allow file-write*
151    (subpath "{worktree_str}")
152    (subpath "/private/tmp")
153    (subpath "/var/folders")
154    (literal "/dev/null")
155)"#
156            );
157
158            // Pass profile inline via -p to avoid temp-file leaks and TOCTOU races.
159            let mut sb_cmd = Command::new("sandbox-exec");
160            sb_cmd.arg("-p").arg(&profile);
161
162            // Extract original
163            sb_cmd.arg(inner_cmd.get_program());
164            for arg in inner_cmd.get_args() {
165                sb_cmd.arg(arg);
166            }
167
168            // Inherit env and dir
169            for (k, v) in inner_cmd.get_envs() {
170                if let Some(v) = v {
171                    sb_cmd.env(k, v);
172                } else {
173                    sb_cmd.env_remove(k);
174                }
175            }
176            if let Some(dir) = inner_cmd.get_current_dir() {
177                sb_cmd.current_dir(dir);
178            }
179
180            Ok(sb_cmd)
181        }
182
183        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
184        {
185            tracing::warn!(
186                "Host-level sandboxing is not supported on this OS. Processes will run unsandboxed."
187            );
188            Ok(inner_cmd)
189        }
190    }
191}
192
193/// The sandbox wrapper program and its argument prefix.
194///
195/// The caller appends the original program and its arguments after these args.
196#[derive(Debug, Clone)]
197pub struct SandboxPrefix {
198    /// The sandbox wrapper program (e.g., `bwrap` or `sandbox-exec`).
199    pub program: OsString,
200    /// Arguments to the sandbox wrapper, NOT including the inner command.
201    pub args: Vec<OsString>,
202}
203
204/// Data-oriented sandbox configuration that produces a wrapper program + args
205/// prefix rather than wrapping a `std::process::Command` directly.
206///
207/// This is useful when the consumer needs a different `Command` type (e.g.,
208/// `tokio::process::Command`) but still wants OS-level sandbox wrapping.
209///
210/// # Example
211///
212/// ```rust,ignore
213/// let config = ProcessSandboxConfig::new("/home/user/project")
214///     .with_network(true)
215///     .with_hidden("/home/user/.astrid");
216///
217/// if let Some(prefix) = config.sandbox_prefix()? {
218///     let mut cmd = tokio::process::Command::new(&prefix.program);
219///     cmd.args(&prefix.args);
220///     cmd.arg("npx").args(["@anthropics/mcp-server-filesystem", "/tmp"]);
221/// }
222/// ```
223/// Distro-aware hint string returned alongside the "sandbox unavailable"
224/// error or warning on Linux. Names the most common cause
225/// (`apparmor_restrict_unprivileged_userns=1` on Ubuntu 24.04+) and the
226/// remediation. Returned by value so the caller can format it into a
227/// larger message.
228#[cfg(target_os = "linux")]
229fn linux_unavailable_hint() -> &'static str {
230    "On Ubuntu 24.04+, this is most often caused by \
231     `kernel.apparmor_restrict_unprivileged_userns=1`. \
232     Fix with: `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` \
233     (or persist via /etc/sysctl.d/). On other distros, ensure the \
234     `bubblewrap` package is installed."
235}
236
237/// Hint string for platforms without a supported OS-level sandbox
238/// implementation (everything except Linux + macOS today).
239#[cfg(not(any(target_os = "linux", target_os = "macos")))]
240fn unsupported_os_hint() -> &'static str {
241    "Astrid currently supports OS-level sandboxing on Linux (bwrap) and \
242     macOS (Seatbelt). On other platforms there is no sandbox layer \
243     available — native subprocess capsules cannot be safely contained."
244}
245
246/// Operator-side policy controlling what happens when OS-level
247/// sandboxing is unavailable (e.g. `bwrap` missing or
248/// `kernel.apparmor_restrict_unprivileged_userns=1` on Ubuntu 24.04+).
249///
250/// Two-state on purpose. The default is [`SandboxPolicy::Required`] —
251/// refuse to launch unsandboxed subprocesses, matching the security
252/// guarantee the README documents. The only escape hatch is
253/// [`SandboxPolicy::Off`], which silently launches without a sandbox.
254///
255/// There is **no** "warn and fall through" middle state. That was the
256/// pre-#655 behaviour and it is exactly the bug: a soft fallback hides
257/// the fact that the security model isn't applying. Either the sandbox
258/// works (`Required`) or the operator explicitly opted out (`Off`). On
259/// a clean target system the kernel sandbox should always be available;
260/// if it isn't, that's a deployment problem to surface, not paper over.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
262pub enum SandboxPolicy {
263    /// Refuse to launch if the OS-level sandbox cannot be applied.
264    /// [`ProcessSandboxConfig::sandbox_prefix`] returns an error with
265    /// an actionable hint (typically the `sysctl` command on Ubuntu
266    /// 24.04+). This is the default — fail loudly rather than silently
267    /// weaken isolation. Production deployments should always run with
268    /// this policy on a properly configured system.
269    #[default]
270    Required,
271    /// Always launch without an OS-level sandbox, no warning. The
272    /// operator has explicitly accepted that subprocess capsules will
273    /// run with the host user's full reach. Use only for trusted dev
274    /// environments, CI runners where the kernel can't be configured
275    /// for unprivileged namespaces, or other situations where the
276    /// trade-off is documented elsewhere.
277    Off,
278}
279
280impl SandboxPolicy {
281    /// Parse a policy name from a configuration string.
282    ///
283    /// Accepted values (case-insensitive): `"required"`, `"off"`.
284    /// Returns the parsed policy on success, `None` on unknown input so
285    /// callers can log the bad value and fall back to the default.
286    #[must_use]
287    pub fn parse(s: &str) -> Option<Self> {
288        match s.trim().to_ascii_lowercase().as_str() {
289            "required" => Some(Self::Required),
290            "off" => Some(Self::Off),
291            _ => None,
292        }
293    }
294
295    /// Resolve the effective policy from the `ASTRID_SANDBOX_POLICY`
296    /// environment variable, falling back to [`Self::default`] when
297    /// unset or unparseable.
298    ///
299    /// A malformed value logs a `warn` so operators see the typo and
300    /// understand they got the default (Required) instead of what they
301    /// asked for.
302    #[must_use]
303    pub fn from_env() -> Self {
304        match std::env::var("ASTRID_SANDBOX_POLICY") {
305            Ok(s) => {
306                if let Some(p) = Self::parse(&s) {
307                    p
308                } else {
309                    tracing::warn!(
310                        value = %s,
311                        "ASTRID_SANDBOX_POLICY value is not one of \
312                         required / off — falling back to `required`"
313                    );
314                    Self::default()
315                }
316            },
317            Err(_) => Self::default(),
318        }
319    }
320}
321
322/// Data-oriented sandbox configuration that produces a wrapper program +
323/// args prefix rather than wrapping a `std::process::Command` directly.
324///
325/// Useful when the consumer needs a different `Command` type (e.g.
326/// `tokio::process::Command`) but still wants OS-level sandbox wrapping.
327/// See [`Self::sandbox_prefix`] for the produced prefix and
328/// [`SandboxPolicy`] for what happens when the OS sandbox is unavailable.
329#[derive(Debug, Clone)]
330pub struct ProcessSandboxConfig {
331    /// Root directory the sandboxed process can write to.
332    writable_root: PathBuf,
333    /// Additional read-only paths beyond the OS defaults.
334    extra_read_paths: Vec<PathBuf>,
335    /// Additional writable paths beyond `writable_root`.
336    extra_write_paths: Vec<PathBuf>,
337    /// Whether to allow network access.
338    allow_network: bool,
339    /// Paths to overlay with empty tmpfs (Linux) or exclude (macOS), blocking access.
340    hidden_paths: Vec<PathBuf>,
341    /// What to do when OS-level sandboxing is unavailable (see [`SandboxPolicy`]).
342    policy: SandboxPolicy,
343}
344
345impl ProcessSandboxConfig {
346    /// Create a new sandbox config with the given writable root.
347    ///
348    /// The default sandbox policy is read from the `ASTRID_SANDBOX_POLICY`
349    /// environment variable (`required` / `off`). When unset
350    /// or unparseable, the policy defaults to [`SandboxPolicy::Required`]:
351    /// callers will get an error from [`Self::sandbox_prefix`] rather than a
352    /// silent unsandboxed launch when the OS-level sandbox can't be applied.
353    #[must_use]
354    pub fn new(writable_root: impl Into<PathBuf>) -> Self {
355        Self {
356            writable_root: writable_root.into(),
357            extra_read_paths: Vec::new(),
358            extra_write_paths: Vec::new(),
359            allow_network: true,
360            hidden_paths: Vec::new(),
361            policy: SandboxPolicy::from_env(),
362        }
363    }
364
365    /// Override the policy for handling unavailable OS-level sandboxing.
366    /// See [`SandboxPolicy`] for the semantics of each variant.
367    #[must_use]
368    pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
369        self.policy = policy;
370        self
371    }
372
373    /// Set whether network access is allowed.
374    #[must_use]
375    pub fn with_network(mut self, allow: bool) -> Self {
376        self.allow_network = allow;
377        self
378    }
379
380    /// Add an additional read-only path.
381    #[must_use]
382    pub fn with_extra_read(mut self, path: impl Into<PathBuf>) -> Self {
383        self.extra_read_paths.push(path.into());
384        self
385    }
386
387    /// Add an additional writable path.
388    #[must_use]
389    pub fn with_extra_write(mut self, path: impl Into<PathBuf>) -> Self {
390        self.extra_write_paths.push(path.into());
391        self
392    }
393
394    /// Add a path to hide from the sandboxed process.
395    ///
396    /// On Linux, this overlays an empty tmpfs. On macOS, the path is
397    /// excluded from the Seatbelt read allowlist.
398    #[must_use]
399    pub fn with_hidden(mut self, path: impl Into<PathBuf>) -> Self {
400        self.hidden_paths.push(path.into());
401        self
402    }
403
404    /// Build the sandbox wrapper prefix for this configuration.
405    ///
406    /// Behaviour depends on the active [`SandboxPolicy`]:
407    /// - [`SandboxPolicy::Required`] (default): returns `Ok(Some(prefix))`
408    ///   when the OS-level sandbox is available, or `Err` with an
409    ///   actionable hint when it is not. Callers should propagate the
410    ///   error and refuse to launch the subprocess — this is what
411    ///   preserves the README's "subprocess capsules are always
412    ///   contained" guarantee.
413    /// - [`SandboxPolicy::Off`]: returns `Ok(None)` unconditionally,
414    ///   without any warning. Use only for trusted dev environments.
415    ///
416    /// # Errors
417    ///
418    /// Returns an error if:
419    /// - Any configured path is not valid UTF-8, not absolute, or
420    ///   contains characters that would break sandbox profile syntax
421    ///   (double-quote, backslash, or null byte).
422    /// - The active policy is [`SandboxPolicy::Required`] and the
423    ///   OS-level sandbox is unavailable. The error message names the
424    ///   most likely cause (`kernel.apparmor_restrict_unprivileged_userns=1`
425    ///   on Ubuntu 24.04+) and the remediation (`sysctl` command or
426    ///   explicit policy override).
427    pub fn sandbox_prefix(&self) -> io::Result<Option<SandboxPrefix>> {
428        // Validate all configured paths up front, regardless of platform.
429        // This ensures the doc contract ("returns Err for non-UTF-8 or
430        // forbidden chars") holds on every OS, not just macOS where SBPL
431        // interpolation makes it exploitable.
432        self.validate_all_paths()?;
433
434        // `Off` short-circuits before any probe so the no-warn contract
435        // is honoured: the operator has explicitly opted out of
436        // subprocess containment and shouldn't see diagnostic noise.
437        if self.policy == SandboxPolicy::Off {
438            return Ok(None);
439        }
440
441        #[cfg(target_os = "linux")]
442        {
443            if bwrap::bwrap_available() {
444                return Ok(Some(self.build_bwrap_prefix()));
445            }
446            self.handle_unavailable_sandbox(linux_unavailable_hint())
447        }
448
449        #[cfg(target_os = "macos")]
450        {
451            // Seatbelt is shipped with macOS and effectively always
452            // available; a failure here is genuinely exceptional (e.g.
453            // path validation tripping a sub-builder), so it surfaces
454            // through `build_seatbelt_prefix` regardless of policy.
455            self.build_seatbelt_prefix().map(Some)
456        }
457
458        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
459        {
460            self.handle_unavailable_sandbox(unsupported_os_hint())
461        }
462    }
463
464    /// Apply the configured `SandboxPolicy` when the OS-level sandbox is
465    /// unavailable. Returns `Err` for `Required`, and never called for
466    /// `Off` (handled upstream in [`Self::sandbox_prefix`]).
467    #[cfg(any(
468        target_os = "linux",
469        not(any(target_os = "linux", target_os = "macos"))
470    ))]
471    fn handle_unavailable_sandbox(&self, hint: &str) -> io::Result<Option<SandboxPrefix>> {
472        match self.policy {
473            SandboxPolicy::Required => Err(io::Error::other(format!(
474                "OS-level sandbox unavailable and policy is `required` — \
475                 refusing to launch native subprocess capsule without \
476                 containment. {hint} To run without the sandbox anyway \
477                 (trusted dev environments, CI runners where the kernel \
478                 can't be configured), set `ASTRID_SANDBOX_POLICY=off`. \
479                 The `required` default exists to keep the security \
480                 guarantee documented in the README — see issue #655."
481            ))),
482            // Unreachable: `Off` short-circuits in `sandbox_prefix`.
483            SandboxPolicy::Off => Ok(None),
484        }
485    }
486
487    /// Validate all configured paths for safe use in sandbox profiles.
488    fn validate_all_paths(&self) -> io::Result<()> {
489        validate_sandbox_str(&self.writable_root, "writable root")?;
490        for p in &self.extra_read_paths {
491            validate_sandbox_str(p, "extra read path")?;
492        }
493        for p in &self.extra_write_paths {
494            validate_sandbox_str(p, "extra write path")?;
495        }
496        for p in &self.hidden_paths {
497            validate_sandbox_str(p, "hidden path")?;
498        }
499        Ok(())
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use std::path::PathBuf;
507
508    /// Validates that a path is safe for interpolation into an SBPL profile string.
509    fn validate_sandbox_path(path: &Path) -> io::Result<()> {
510        let s = path.to_str().ok_or_else(|| {
511            io::Error::new(
512                io::ErrorKind::InvalidInput,
513                format!("sandbox path is not valid UTF-8: {}", path.display()),
514            )
515        })?;
516        if s.contains(['"', '\\', '\0']) {
517            return Err(io::Error::new(
518                io::ErrorKind::InvalidInput,
519                format!(
520                    "sandbox path contains forbidden characters (double-quote, backslash, or null): {}",
521                    path.display()
522                ),
523            ));
524        }
525        Ok(())
526    }
527
528    // --- validate_sandbox_path tests ---
529
530    #[test]
531    fn validate_sandbox_path_accepts_normal_path() {
532        let path = PathBuf::from("/Users/agent/workspace/project");
533        assert!(validate_sandbox_path(&path).is_ok());
534    }
535
536    #[test]
537    fn validate_sandbox_path_accepts_path_with_spaces() {
538        let path = PathBuf::from("/Users/agent/my project/src");
539        assert!(validate_sandbox_path(&path).is_ok());
540    }
541
542    #[test]
543    fn validate_sandbox_path_rejects_double_quote() {
544        let path = PathBuf::from("/Users/agent/work\"inject");
545        let err = validate_sandbox_path(&path).unwrap_err();
546        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
547        assert!(
548            err.to_string().contains("forbidden characters"),
549            "unexpected error message: {err}"
550        );
551    }
552
553    #[test]
554    fn validate_sandbox_path_rejects_sbpl_injection_payload() {
555        // Simulates an actual SBPL escape attempt.
556        let path = PathBuf::from(r#"/tmp/evil") (allow file-write* (subpath "/"))"#);
557        let err = validate_sandbox_path(&path).unwrap_err();
558        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
559        assert!(
560            err.to_string().contains("forbidden characters"),
561            "unexpected error message: {err}"
562        );
563    }
564
565    #[test]
566    fn validate_sandbox_path_rejects_backslash() {
567        let path = PathBuf::from("/tmp/work\\nspace");
568        let err = validate_sandbox_path(&path).unwrap_err();
569        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
570        assert!(
571            err.to_string().contains("forbidden characters"),
572            "unexpected error message: {err}"
573        );
574    }
575
576    #[test]
577    fn validate_sandbox_path_rejects_null_byte() {
578        let path = PathBuf::from("/tmp/work\0space");
579        let err = validate_sandbox_path(&path).unwrap_err();
580        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
581        assert!(
582            err.to_string().contains("forbidden characters"),
583            "unexpected error message: {err}"
584        );
585    }
586
587    // --- SandboxCommand::wrap() tests ---
588
589    #[test]
590    fn test_wrap_rejects_non_utf8_path() {
591        use std::ffi::OsStr;
592        use std::os::unix::ffi::OsStrExt;
593
594        let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
595        let bad_path = Path::new(OsStr::from_bytes(bad_bytes));
596        let cmd = Command::new("echo");
597        let result = SandboxCommand::wrap(cmd, bad_path);
598        assert!(result.is_err());
599        let err_msg = result.unwrap_err().to_string();
600        assert!(
601            err_msg.contains("not valid UTF-8"),
602            "error should mention UTF-8: {err_msg}"
603        );
604    }
605
606    #[test]
607    fn test_wrap_rejects_double_quote_path() {
608        let bad_path = Path::new("/tmp/evil\"injection/workspace");
609        let cmd = Command::new("echo");
610        let result = SandboxCommand::wrap(cmd, bad_path);
611        assert!(result.is_err());
612        let err_msg = result.unwrap_err().to_string();
613        assert!(
614            err_msg.contains("forbidden characters"),
615            "error should mention forbidden chars: {err_msg}"
616        );
617    }
618
619    #[test]
620    fn test_wrap_rejects_null_byte_path() {
621        let bad_path = Path::new("/tmp/evil\0null/workspace");
622        let cmd = Command::new("echo");
623        let result = SandboxCommand::wrap(cmd, bad_path);
624        assert!(result.is_err());
625        let err_msg = result.unwrap_err().to_string();
626        assert!(
627            err_msg.contains("forbidden characters"),
628            "error should mention forbidden chars: {err_msg}"
629        );
630    }
631
632    #[test]
633    fn test_wrap_rejects_backslash_path() {
634        let bad_path = Path::new("/tmp/work\\nspace");
635        let cmd = Command::new("echo");
636        let result = SandboxCommand::wrap(cmd, bad_path);
637        assert!(result.is_err());
638        let err_msg = result.unwrap_err().to_string();
639        assert!(
640            err_msg.contains("forbidden characters"),
641            "error should mention forbidden chars: {err_msg}"
642        );
643    }
644
645    #[test]
646    fn test_wrap_rejects_relative_path() {
647        let bad_path = Path::new("relative/workspace");
648        let cmd = Command::new("echo");
649        let result = SandboxCommand::wrap(cmd, bad_path);
650        assert!(result.is_err());
651        let err_msg = result.unwrap_err().to_string();
652        assert!(
653            err_msg.contains("absolute path"),
654            "error should mention absolute path: {err_msg}"
655        );
656    }
657
658    #[cfg(target_os = "macos")]
659    #[test]
660    fn wrap_uses_inline_profile() {
661        let cmd = Command::new("echo");
662        let path = PathBuf::from("/tmp/safe-workspace");
663        let wrapped = SandboxCommand::wrap(cmd, &path).unwrap();
664
665        if super::seatbelt::darwin_major_version() >= 24 {
666            assert_eq!(
667                wrapped.get_program(),
668                "echo",
669                "on macOS 15+, command should pass through unwrapped"
670            );
671        } else {
672            let args: Vec<_> = wrapped.get_args().collect();
673            assert_eq!(args[0], "-p", "expected -p for inline profile delivery");
674            let profile = args[1].to_string_lossy();
675            assert!(
676                profile.contains("/tmp/safe-workspace"),
677                "profile should contain the worktree path"
678            );
679        }
680    }
681
682    // --- ProcessSandboxConfig builder tests ---
683
684    #[test]
685    fn test_sandbox_config_builder() {
686        let config = ProcessSandboxConfig::new("/project")
687            .with_network(false)
688            .with_extra_read("/data")
689            .with_extra_write("/output")
690            .with_hidden("/home/user/.astrid");
691
692        assert_eq!(config.writable_root, PathBuf::from("/project"));
693        assert!(!config.allow_network);
694        assert_eq!(config.extra_read_paths, vec![PathBuf::from("/data")]);
695        assert_eq!(config.extra_write_paths, vec![PathBuf::from("/output")]);
696        assert_eq!(
697            config.hidden_paths,
698            vec![PathBuf::from("/home/user/.astrid")]
699        );
700    }
701
702    #[test]
703    fn test_sandbox_config_defaults() {
704        let config = ProcessSandboxConfig::new("/project");
705        assert!(config.allow_network);
706        assert!(config.extra_read_paths.is_empty());
707        assert!(config.extra_write_paths.is_empty());
708        assert!(config.hidden_paths.is_empty());
709    }
710
711    // --- SandboxPolicy tests ---
712
713    #[test]
714    fn policy_parse_accepts_known_values() {
715        assert_eq!(
716            SandboxPolicy::parse("required"),
717            Some(SandboxPolicy::Required)
718        );
719        assert_eq!(
720            SandboxPolicy::parse("Required"),
721            Some(SandboxPolicy::Required)
722        );
723        assert_eq!(SandboxPolicy::parse("OFF"), Some(SandboxPolicy::Off));
724        assert_eq!(SandboxPolicy::parse("  off  "), Some(SandboxPolicy::Off));
725    }
726
727    #[test]
728    fn policy_parse_rejects_unknown_values() {
729        assert_eq!(SandboxPolicy::parse(""), None);
730        // The pre-#655 "warn and fall through" middle state was removed
731        // intentionally — `preferred` is no longer a valid policy.
732        assert_eq!(SandboxPolicy::parse("preferred"), None);
733        assert_eq!(SandboxPolicy::parse("relaxed"), None);
734        assert_eq!(SandboxPolicy::parse("required-ish"), None);
735    }
736
737    #[test]
738    fn policy_default_is_required() {
739        assert_eq!(SandboxPolicy::default(), SandboxPolicy::Required);
740    }
741
742    #[test]
743    #[allow(unsafe_code)] // env mutation is unsafe in 2024 edition; see SAFETY note below
744    fn config_default_policy_is_required_when_env_unset() {
745        // The bug from #655: a fresh `ProcessSandboxConfig::new(...)`
746        // silently bypassing the sandbox. The constructor reads
747        // `ASTRID_SANDBOX_POLICY` from the env, falling back to
748        // `Required`. Confirm it lands on `Required` when the env var
749        // is unset.
750        //
751        // SAFETY: `std::env::remove_var` is unsafe in 2024 edition
752        // because env mutation isn't thread-safe; this test is racy if
753        // another test concurrently sets the same var. None do — the
754        // policy-test set is the only consumer.
755        unsafe {
756            std::env::remove_var("ASTRID_SANDBOX_POLICY");
757        }
758        let config = ProcessSandboxConfig::new("/project");
759        assert_eq!(
760            config.policy,
761            SandboxPolicy::Required,
762            "fresh config with unset env must default to Required — \
763             silent unsandboxed launches are the bug from #655"
764        );
765    }
766
767    #[test]
768    fn with_policy_overrides_default() {
769        let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
770        assert_eq!(config.policy, SandboxPolicy::Off);
771    }
772
773    #[test]
774    fn sandbox_prefix_with_off_policy_returns_none_silently() {
775        // `Off` short-circuits and returns None regardless of platform
776        // sandbox availability.
777        let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
778        let result = config.sandbox_prefix();
779        assert!(matches!(result, Ok(None)));
780    }
781
782    // The Required-vs-Preferred behaviour around real bwrap availability
783    // is platform-specific and probe-cached, so it's covered by the
784    // bwrap-targeted tests below rather than reproduced here.
785
786    // --- Cross-platform sandbox_prefix() rejection tests ---
787
788    #[test]
789    fn test_sandbox_prefix_rejects_relative_writable_root() {
790        let config = ProcessSandboxConfig::new("relative/project");
791        assert!(config.sandbox_prefix().is_err());
792    }
793
794    #[test]
795    fn test_sandbox_prefix_rejects_non_utf8_writable_root() {
796        use std::ffi::OsStr;
797        use std::os::unix::ffi::OsStrExt;
798
799        let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
800        let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
801        let config = ProcessSandboxConfig::new(bad_path);
802        let result = config.sandbox_prefix();
803        assert!(result.is_err());
804        assert!(result.unwrap_err().to_string().contains("not valid UTF-8"));
805    }
806
807    #[test]
808    fn test_sandbox_prefix_rejects_non_utf8_extra_paths() {
809        use std::ffi::OsStr;
810        use std::os::unix::ffi::OsStrExt;
811
812        let bad_bytes: &[u8] = b"/data/\xff\xfe";
813        let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
814
815        let config = ProcessSandboxConfig::new("/project").with_extra_read(bad_path.clone());
816        assert!(config.sandbox_prefix().is_err());
817
818        let config = ProcessSandboxConfig::new("/project").with_extra_write(bad_path.clone());
819        assert!(config.sandbox_prefix().is_err());
820
821        let config = ProcessSandboxConfig::new("/project").with_hidden(bad_path);
822        assert!(config.sandbox_prefix().is_err());
823    }
824
825    #[test]
826    fn test_sandbox_prefix_rejects_double_quote_in_paths() {
827        let config = ProcessSandboxConfig::new("/project/evil\"dir");
828        assert!(config.sandbox_prefix().is_err());
829
830        let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\"path");
831        assert!(config.sandbox_prefix().is_err());
832
833        let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\"path");
834        assert!(config.sandbox_prefix().is_err());
835
836        let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\"path");
837        assert!(config.sandbox_prefix().is_err());
838    }
839
840    #[test]
841    fn test_sandbox_prefix_rejects_backslash_in_paths() {
842        let config = ProcessSandboxConfig::new("/project/evil\\dir");
843        assert!(config.sandbox_prefix().is_err());
844
845        let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\\path");
846        assert!(config.sandbox_prefix().is_err());
847
848        let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\\path");
849        assert!(config.sandbox_prefix().is_err());
850
851        let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\\path");
852        assert!(config.sandbox_prefix().is_err());
853    }
854
855    #[test]
856    fn test_sandbox_prefix_rejects_null_byte_in_paths() {
857        let config = ProcessSandboxConfig::new("/project/evil\0dir");
858        assert!(config.sandbox_prefix().is_err());
859
860        let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\0path");
861        assert!(config.sandbox_prefix().is_err());
862
863        let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\0path");
864        assert!(config.sandbox_prefix().is_err());
865
866        let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\0path");
867        assert!(config.sandbox_prefix().is_err());
868    }
869}