Skip to main content

zeph_tools/sandbox/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! OS-level sandbox abstractions for subprocess tool execution.
5//!
6//! This module provides a portable [`Sandbox`] trait and platform-specific backends that
7//! restrict filesystem, network, and syscall access for shell commands spawned by
8//! `ShellExecutor`.
9//!
10//! # Scope (NFR-SB-1)
11//!
12//! The sandbox applies **only to subprocess executors** (`ShellExecutor`). In-process executors
13//! (`WebScrapeExecutor`, `FileExecutor`) do not spawn a child process and are therefore not
14//! subject to OS-level sandboxing. Application-layer controls (allowed hosts, path allowlists)
15//! govern those executors instead.
16//!
17//! # Platform support
18//!
19//! | Platform | Backend | Compiled |
20//! |----------|---------|----------|
21//! | macOS | `sandbox-exec` (Seatbelt) | always |
22//! | Linux + `sandbox` feature | `bwrap` + Landlock + seccomp | `#[cfg(all(target_os="linux", feature="sandbox"))]` |
23//! | Other | `NoopSandbox` (logs WARN) | always |
24//!
25//! # Example
26//!
27//! ```rust,no_run
28//! use zeph_tools::sandbox::{build_sandbox, SandboxPolicy, SandboxProfile};
29//! use tokio::process::Command;
30//!
31//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
32//! let policy = SandboxPolicy {
33//!     profile: SandboxProfile::Workspace,
34//!     allow_read: vec![],
35//!     allow_write: vec![std::env::current_dir()?],
36//!     allow_network: false,
37//!     allow_exec: vec![],
38//!     env_inherit: vec![],
39//!     denied_domains: vec![],
40//! };
41//! let sb = build_sandbox(false)?;
42//! let mut cmd = Command::new("bash");
43//! cmd.arg("-c").arg("echo hello");
44//! sb.wrap(&mut cmd, &policy)?;
45//! # Ok(())
46//! # }
47//! ```
48
49use std::path::PathBuf;
50
51use serde::{Deserialize, Serialize};
52use thiserror::Error;
53
54pub mod noop;
55
56#[cfg(target_os = "macos")]
57pub mod macos;
58
59#[cfg(all(target_os = "linux", feature = "sandbox"))]
60pub mod linux;
61
62pub use noop::NoopSandbox;
63
64#[cfg(target_os = "macos")]
65pub use macos::MacosSandbox;
66
67#[cfg(all(target_os = "linux", feature = "sandbox"))]
68pub use linux::LinuxSandbox;
69
70/// Declarative sandbox policy evaluated at command launch.
71///
72/// Applied *after* blocklist, `PolicyGate`, and `TrustGate` have accepted the call.
73/// The sandbox is the last hard boundary, not a replacement for application-level controls.
74#[derive(Debug, Clone)]
75pub struct SandboxPolicy {
76    /// The enforcement profile controlling which restrictions are active.
77    pub profile: SandboxProfile,
78    /// Paths granted read (and execute) access. Normalized to absolute paths at construction.
79    ///
80    /// Paths are resolved to their canonical (real) form by [`SandboxPolicy::canonicalized`]
81    /// before being applied. If a path is a symlink, the resolved target is used for the allow
82    /// rule. Deny rules for well-known secret paths are also generated for the canonical form,
83    /// so the allow override works correctly even when the denied path is a symlink.
84    pub allow_read: Vec<PathBuf>,
85    /// Paths granted read and write access. Normalized to absolute paths at construction.
86    pub allow_write: Vec<PathBuf>,
87    /// Whether unrestricted network egress is permitted.
88    pub allow_network: bool,
89    /// Additional executables or directories granted execute permission.
90    pub allow_exec: Vec<PathBuf>,
91    /// Environment variable names or prefixes that are inherited by the sandboxed child.
92    pub env_inherit: Vec<String>,
93    /// Hostname patterns (exact or `*.suffix`) denied network egress.
94    ///
95    /// Enforcement is per-backend:
96    /// - macOS Seatbelt: `(deny network* (remote host "<host>"))` after `(allow network*)`.
97    /// - Linux bwrap: `/etc/hosts` override resolving the name to `0.0.0.0` (best-effort).
98    /// - [`NoopSandbox`]: ignored (log WARN at construction if non-empty).
99    pub denied_domains: Vec<String>,
100}
101
102impl SandboxPolicy {
103    /// Canonicalize all path fields so that symlinks and `..` components cannot bypass
104    /// the policy. Paths that cannot be resolved (e.g., non-existent) are dropped and
105    /// logged at WARN level with the OS error — callers should ensure paths exist
106    /// before adding them to the policy.
107    #[must_use]
108    pub fn canonicalized(mut self) -> Self {
109        self.allow_read = canonicalize_paths(self.allow_read);
110        self.allow_write = canonicalize_paths(self.allow_write);
111        self.allow_exec = canonicalize_paths(self.allow_exec);
112        self
113    }
114}
115
116fn canonicalize_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
117    paths
118        .into_iter()
119        .filter_map(|p| match std::fs::canonicalize(&p) {
120            Ok(canonical) => {
121                if canonical != p {
122                    tracing::debug!(
123                        "sandbox: resolved symlink {} → {}",
124                        p.display(),
125                        canonical.display()
126                    );
127                }
128                Some(canonical)
129            }
130            Err(e) => {
131                tracing::warn!(
132                    path = %p.display(),
133                    error = %e,
134                    "sandbox: allow-list path could not be canonicalized and was dropped from policy"
135                );
136                None
137            }
138        })
139        .collect()
140}
141
142impl Default for SandboxPolicy {
143    fn default() -> Self {
144        let cwd =
145            std::fs::canonicalize(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
146                .unwrap_or_else(|_| PathBuf::from("/"));
147        Self {
148            profile: SandboxProfile::Workspace,
149            allow_read: vec![cwd.clone()],
150            allow_write: vec![cwd],
151            allow_network: false,
152            allow_exec: vec![],
153            env_inherit: vec![],
154            denied_domains: vec![],
155        }
156    }
157}
158
159/// Portable sandbox enforcement profile.
160///
161/// The profile sets the _baseline_ restrictions. `allow_read`, `allow_write`, and
162/// `allow_network` in [`SandboxPolicy`] further refine what is permitted.
163#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
164#[serde(rename_all = "kebab-case")]
165pub enum SandboxProfile {
166    /// Read-only to `allow_read` paths, no writes, no network. Exec restricted to `allow_exec` + bash.
167    ReadOnly,
168    /// Read/write to configured paths; network egress blocked.
169    #[default]
170    Workspace,
171    /// Workspace-level filesystem access plus unrestricted network egress.
172    ///
173    /// Does **not** curate host/port allowlists. Use application-layer controls for that.
174    #[serde(rename = "network-allow-all", alias = "network")]
175    NetworkAllowAll,
176    /// Sandbox disabled. The subprocess inherits the parent's full capabilities.
177    ///
178    /// Config authors must set this explicitly to opt out.
179    Off,
180}
181
182/// Error returned when sandbox setup or policy application fails.
183#[derive(Debug, Error)]
184pub enum SandboxError {
185    /// The OS backend binary or kernel API is unavailable on this system.
186    #[error("sandbox backend unavailable: {reason}")]
187    Unavailable { reason: String },
188    /// The configured policy is not supported by the backend.
189    #[error("policy not supported by {backend}: {reason}")]
190    UnsupportedPolicy {
191        /// Backend name for diagnostics.
192        backend: &'static str,
193        /// Human-readable explanation.
194        reason: String,
195    },
196    /// I/O error during sandbox setup (e.g. temp file creation).
197    #[error("sandbox setup failed: {0}")]
198    Setup(#[from] std::io::Error),
199    /// Policy string generation failed.
200    #[error("policy generation failed: {0}")]
201    Policy(String),
202}
203
204/// Operating-system sandbox backend.
205///
206/// `wrap` is the sole entry point. Implementations rewrite a [`tokio::process::Command`]
207/// in place so that the next `.spawn()` launches inside the OS sandbox. Implementations
208/// must be fork-safe: state installed via the command builder must survive `fork()+exec()`.
209///
210/// # Contract for implementors
211///
212/// - Must not spawn the child themselves — only rewrite `cmd`.
213/// - Must not use `unsafe` code.
214/// - When the profile is [`SandboxProfile::Off`], `wrap` MUST be a no-op.
215pub trait Sandbox: Send + Sync + std::fmt::Debug {
216    /// Short identifier for logging and diagnostics (e.g., `"macos-seatbelt"`, `"linux-bwrap"`).
217    fn name(&self) -> &'static str;
218
219    /// Verify that `policy` is expressible on this backend.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`SandboxError::UnsupportedPolicy`] when a required feature is missing.
224    fn supports(&self, policy: &SandboxPolicy) -> Result<(), SandboxError>;
225
226    /// Rewrite `cmd` to execute inside the OS sandbox described by `policy`.
227    ///
228    /// Called synchronously in the executor thread. Must not block on I/O for more than a few
229    /// milliseconds (temp file writes are acceptable; network calls are not).
230    ///
231    /// # Errors
232    ///
233    /// Returns [`SandboxError`] if wrapping fails (binary missing, profile generation error, etc.).
234    fn wrap(
235        &self,
236        cmd: &mut tokio::process::Command,
237        policy: &SandboxPolicy,
238    ) -> Result<(), SandboxError>;
239}
240
241/// Construct the best available [`Sandbox`] backend for the current platform.
242///
243/// Selection order:
244/// 1. macOS → `MacosSandbox`
245/// 2. Linux + `sandbox` feature → `LinuxSandbox`
246/// 3. Fallback → [`NoopSandbox`]
247///
248/// # Errors
249///
250/// Returns [`SandboxError::Unavailable`] when `strict = true` and the preferred backend
251/// is missing (e.g. `bwrap` not on `PATH`).
252pub fn build_sandbox(strict: bool) -> Result<Box<dyn Sandbox>, SandboxError> {
253    build_sandbox_with_policy(strict, false, false)
254}
255
256/// Construct the best available [`Sandbox`] backend with additional safety options.
257///
258/// Extends [`build_sandbox`] with:
259/// - `fail_if_unavailable`: when `true`, even a successful noop fallback is an error.
260///   Use this when `denied_domains` must be enforced and no effective sandbox exists.
261/// - `denied_domains_present`: when `true` and the noop backend is selected with
262///   `fail_if_unavailable = false`, emits a one-shot `WARN` that the deny list is unenforceable.
263///
264/// # Errors
265///
266/// - [`SandboxError::Unavailable`] when `strict = true` and the preferred backend binary is
267///   missing (same as [`build_sandbox`]).
268/// - [`SandboxError::Unavailable`] when `fail_if_unavailable = true` and noop would be selected.
269pub fn build_sandbox_with_policy(
270    strict: bool,
271    fail_if_unavailable: bool,
272    denied_domains_present: bool,
273) -> Result<Box<dyn Sandbox>, SandboxError> {
274    #[cfg(target_os = "macos")]
275    {
276        let _ = (strict, fail_if_unavailable, denied_domains_present);
277        Ok(Box::new(MacosSandbox::new()))
278    }
279
280    #[cfg(all(target_os = "linux", feature = "sandbox"))]
281    {
282        let _ = (fail_if_unavailable, denied_domains_present);
283        linux::LinuxSandbox::new(strict).map(|s| Box::new(s) as Box<dyn Sandbox>)
284    }
285
286    // Noop path: platform without an OS sandbox backend.
287    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
288    {
289        if strict {
290            return Err(SandboxError::Unavailable {
291                reason: "OS sandbox not supported on this platform and strict=true".into(),
292            });
293        }
294        if fail_if_unavailable {
295            return Err(SandboxError::Unavailable {
296                reason: "noop backend selected but fail_if_unavailable=true; \
297                         OS sandbox is required on this platform"
298                    .into(),
299            });
300        }
301        if denied_domains_present {
302            tracing::warn!(
303                "sandbox.denied_domains is set but the OS sandbox is unavailable on this platform \
304                 — denied domains cannot be enforced; set fail_if_unavailable=true to make this a \
305                 startup error"
306            );
307        } else {
308            tracing::warn!(
309                "OS sandbox not supported on this platform — running without subprocess isolation"
310            );
311        }
312        Ok(Box::new(NoopSandbox))
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    #[test]
319    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
320    fn build_sandbox_strict_fails_when_unsupported() {
321        use super::{SandboxError, build_sandbox};
322        let err = build_sandbox(true).expect_err("strict must fail on unsupported platform");
323        assert!(matches!(err, SandboxError::Unavailable { .. }));
324    }
325
326    #[test]
327    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
328    fn build_sandbox_nonstrict_falls_back_to_noop() {
329        use super::build_sandbox;
330        let sb = build_sandbox(false).expect("noop fallback ok");
331        assert_eq!(sb.name(), "noop");
332    }
333
334    #[test]
335    fn canonicalize_paths_drops_nonexistent_path() {
336        use super::{SandboxPolicy, SandboxProfile};
337        use std::path::PathBuf;
338
339        let policy = SandboxPolicy {
340            profile: SandboxProfile::Workspace,
341            allow_read: vec![PathBuf::from(
342                "/this/path/does/not/exist/zeph-test-sentinel",
343            )],
344            allow_write: vec![],
345            allow_network: false,
346            allow_exec: vec![],
347            env_inherit: vec![],
348            denied_domains: vec![],
349        }
350        .canonicalized();
351
352        assert!(
353            policy.allow_read.is_empty(),
354            "non-existent path must be dropped by canonicalized()"
355        );
356    }
357}