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//! };
40//! let sb = build_sandbox(false)?;
41//! let mut cmd = Command::new("bash");
42//! cmd.arg("-c").arg("echo hello");
43//! sb.wrap(&mut cmd, &policy)?;
44//! # Ok(())
45//! # }
46//! ```
47
48use std::path::PathBuf;
49
50use serde::{Deserialize, Serialize};
51use thiserror::Error;
52
53pub mod noop;
54
55#[cfg(target_os = "macos")]
56pub mod macos;
57
58#[cfg(all(target_os = "linux", feature = "sandbox"))]
59pub mod linux;
60
61pub use noop::NoopSandbox;
62
63#[cfg(target_os = "macos")]
64pub use macos::MacosSandbox;
65
66#[cfg(all(target_os = "linux", feature = "sandbox"))]
67pub use linux::LinuxSandbox;
68
69/// Declarative sandbox policy evaluated at command launch.
70///
71/// Applied *after* blocklist, `PolicyGate`, and `TrustGate` have accepted the call.
72/// The sandbox is the last hard boundary, not a replacement for application-level controls.
73#[derive(Debug, Clone)]
74pub struct SandboxPolicy {
75    /// The enforcement profile controlling which restrictions are active.
76    pub profile: SandboxProfile,
77    /// Paths granted read (and execute) access. Normalized to absolute paths at construction.
78    ///
79    /// Paths are resolved to their canonical (real) form by [`SandboxPolicy::canonicalized`]
80    /// before being applied. If a path is a symlink, the resolved target is used for the allow
81    /// rule. Deny rules for well-known secret paths are also generated for the canonical form,
82    /// so the allow override works correctly even when the denied path is a symlink.
83    pub allow_read: Vec<PathBuf>,
84    /// Paths granted read and write access. Normalized to absolute paths at construction.
85    pub allow_write: Vec<PathBuf>,
86    /// Whether unrestricted network egress is permitted.
87    pub allow_network: bool,
88    /// Additional executables or directories granted execute permission.
89    pub allow_exec: Vec<PathBuf>,
90    /// Environment variable names or prefixes that are inherited by the sandboxed child.
91    pub env_inherit: Vec<String>,
92}
93
94impl SandboxPolicy {
95    /// Canonicalize all path fields so that symlinks and `..` components cannot bypass
96    /// the policy. Paths that cannot be resolved (e.g., non-existent) are dropped and
97    /// logged at WARN level with the OS error — callers should ensure paths exist
98    /// before adding them to the policy.
99    #[must_use]
100    pub fn canonicalized(mut self) -> Self {
101        self.allow_read = canonicalize_paths(self.allow_read);
102        self.allow_write = canonicalize_paths(self.allow_write);
103        self.allow_exec = canonicalize_paths(self.allow_exec);
104        self
105    }
106}
107
108fn canonicalize_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
109    paths
110        .into_iter()
111        .filter_map(|p| match std::fs::canonicalize(&p) {
112            Ok(canonical) => {
113                if canonical != p {
114                    tracing::debug!(
115                        "sandbox: resolved symlink {} → {}",
116                        p.display(),
117                        canonical.display()
118                    );
119                }
120                Some(canonical)
121            }
122            Err(e) => {
123                tracing::warn!(
124                    path = %p.display(),
125                    error = %e,
126                    "sandbox: allow-list path could not be canonicalized and was dropped from policy"
127                );
128                None
129            }
130        })
131        .collect()
132}
133
134impl Default for SandboxPolicy {
135    fn default() -> Self {
136        let cwd =
137            std::fs::canonicalize(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
138                .unwrap_or_else(|_| PathBuf::from("/"));
139        Self {
140            profile: SandboxProfile::Workspace,
141            allow_read: vec![cwd.clone()],
142            allow_write: vec![cwd],
143            allow_network: false,
144            allow_exec: vec![],
145            env_inherit: vec![],
146        }
147    }
148}
149
150/// Portable sandbox enforcement profile.
151///
152/// The profile sets the _baseline_ restrictions. `allow_read`, `allow_write`, and
153/// `allow_network` in [`SandboxPolicy`] further refine what is permitted.
154#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
155#[serde(rename_all = "kebab-case")]
156pub enum SandboxProfile {
157    /// Read-only to `allow_read` paths, no writes, no network. Exec restricted to `allow_exec` + bash.
158    ReadOnly,
159    /// Read/write to configured paths; network egress blocked.
160    #[default]
161    Workspace,
162    /// Workspace-level filesystem access plus unrestricted network egress.
163    ///
164    /// Does **not** curate host/port allowlists. Use application-layer controls for that.
165    #[serde(rename = "network-allow-all", alias = "network")]
166    NetworkAllowAll,
167    /// Sandbox disabled. The subprocess inherits the parent's full capabilities.
168    ///
169    /// Config authors must set this explicitly to opt out.
170    Off,
171}
172
173/// Error returned when sandbox setup or policy application fails.
174#[derive(Debug, Error)]
175pub enum SandboxError {
176    /// The OS backend binary or kernel API is unavailable on this system.
177    #[error("sandbox backend unavailable: {reason}")]
178    Unavailable { reason: String },
179    /// The configured policy is not supported by the backend.
180    #[error("policy not supported by {backend}: {reason}")]
181    UnsupportedPolicy {
182        /// Backend name for diagnostics.
183        backend: &'static str,
184        /// Human-readable explanation.
185        reason: String,
186    },
187    /// I/O error during sandbox setup (e.g. temp file creation).
188    #[error("sandbox setup failed: {0}")]
189    Setup(#[from] std::io::Error),
190    /// Policy string generation failed.
191    #[error("policy generation failed: {0}")]
192    Policy(String),
193}
194
195/// Operating-system sandbox backend.
196///
197/// `wrap` is the sole entry point. Implementations rewrite a [`tokio::process::Command`]
198/// in place so that the next `.spawn()` launches inside the OS sandbox. Implementations
199/// must be fork-safe: state installed via the command builder must survive `fork()+exec()`.
200///
201/// # Contract for implementors
202///
203/// - Must not spawn the child themselves — only rewrite `cmd`.
204/// - Must not use `unsafe` code.
205/// - When the profile is [`SandboxProfile::Off`], `wrap` MUST be a no-op.
206pub trait Sandbox: Send + Sync + std::fmt::Debug {
207    /// Short identifier for logging and diagnostics (e.g., `"macos-seatbelt"`, `"linux-bwrap"`).
208    fn name(&self) -> &'static str;
209
210    /// Verify that `policy` is expressible on this backend.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`SandboxError::UnsupportedPolicy`] when a required feature is missing.
215    fn supports(&self, policy: &SandboxPolicy) -> Result<(), SandboxError>;
216
217    /// Rewrite `cmd` to execute inside the OS sandbox described by `policy`.
218    ///
219    /// Called synchronously in the executor thread. Must not block on I/O for more than a few
220    /// milliseconds (temp file writes are acceptable; network calls are not).
221    ///
222    /// # Errors
223    ///
224    /// Returns [`SandboxError`] if wrapping fails (binary missing, profile generation error, etc.).
225    fn wrap(
226        &self,
227        cmd: &mut tokio::process::Command,
228        policy: &SandboxPolicy,
229    ) -> Result<(), SandboxError>;
230}
231
232/// Construct the best available [`Sandbox`] backend for the current platform.
233///
234/// Selection order:
235/// 1. macOS → `MacosSandbox`
236/// 2. Linux + `sandbox` feature → `LinuxSandbox`
237/// 3. Fallback → [`NoopSandbox`]
238///
239/// # Errors
240///
241/// Returns [`SandboxError::Unavailable`] when `strict = true` and the preferred backend
242/// is missing (e.g. `bwrap` not on `PATH`).
243pub fn build_sandbox(strict: bool) -> Result<Box<dyn Sandbox>, SandboxError> {
244    #[cfg(target_os = "macos")]
245    {
246        let _ = strict;
247        Ok(Box::new(MacosSandbox::new()))
248    }
249
250    #[cfg(all(target_os = "linux", feature = "sandbox"))]
251    {
252        linux::LinuxSandbox::new(strict).map(|s| Box::new(s) as Box<dyn Sandbox>)
253    }
254
255    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
256    {
257        if strict {
258            return Err(SandboxError::Unavailable {
259                reason: "OS sandbox not supported on this platform and strict=true".into(),
260            });
261        }
262        tracing::warn!(
263            "OS sandbox not supported on this platform — running without subprocess isolation"
264        );
265        Ok(Box::new(NoopSandbox))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    #[test]
272    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
273    fn build_sandbox_strict_fails_when_unsupported() {
274        use super::{SandboxError, build_sandbox};
275        let err = build_sandbox(true).expect_err("strict must fail on unsupported platform");
276        assert!(matches!(err, SandboxError::Unavailable { .. }));
277    }
278
279    #[test]
280    #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
281    fn build_sandbox_nonstrict_falls_back_to_noop() {
282        use super::build_sandbox;
283        let sb = build_sandbox(false).expect("noop fallback ok");
284        assert_eq!(sb.name(), "noop");
285    }
286
287    #[test]
288    fn canonicalize_paths_drops_nonexistent_path() {
289        use super::{SandboxPolicy, SandboxProfile};
290        use std::path::PathBuf;
291
292        let policy = SandboxPolicy {
293            profile: SandboxProfile::Workspace,
294            allow_read: vec![PathBuf::from(
295                "/this/path/does/not/exist/zeph-test-sentinel",
296            )],
297            allow_write: vec![],
298            allow_network: false,
299            allow_exec: vec![],
300            env_inherit: vec![],
301        }
302        .canonicalized();
303
304        assert!(
305            policy.allow_read.is_empty(),
306            "non-existent path must be dropped by canonicalized()"
307        );
308    }
309}