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::{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 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 /// Hostname patterns (exact or `*.suffix`) denied network egress.
93 ///
94 /// Enforcement is per-backend:
95 /// - macOS Seatbelt: `(deny network* (remote host "<host>"))` after `(allow network*)`.
96 /// - Linux bwrap: `/etc/hosts` override resolving the name to `0.0.0.0` (best-effort).
97 /// - [`NoopSandbox`]: ignored (log WARN at construction if non-empty).
98 pub denied_domains: Vec<String>,
99}
100
101impl SandboxPolicy {
102 /// Canonicalize all path fields so that symlinks and `..` components cannot bypass
103 /// the policy. Paths that cannot be resolved (e.g., non-existent) are dropped and
104 /// logged at WARN level with the OS error — callers should ensure paths exist
105 /// before adding them to the policy.
106 #[must_use]
107 pub fn canonicalized(mut self) -> Self {
108 self.allow_read = canonicalize_paths(self.allow_read);
109 self.allow_write = canonicalize_paths(self.allow_write);
110 self.allow_exec = canonicalize_paths(self.allow_exec);
111 self
112 }
113}
114
115fn canonicalize_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
116 paths
117 .into_iter()
118 .filter_map(|p| match std::fs::canonicalize(&p) {
119 Ok(canonical) => {
120 if canonical != p {
121 tracing::debug!(
122 "sandbox: resolved symlink {} → {}",
123 p.display(),
124 canonical.display()
125 );
126 }
127 Some(canonical)
128 }
129 Err(e) => {
130 tracing::warn!(
131 path = %p.display(),
132 error = %e,
133 "sandbox: allow-list path could not be canonicalized and was dropped from policy"
134 );
135 None
136 }
137 })
138 .collect()
139}
140
141impl Default for SandboxPolicy {
142 fn default() -> Self {
143 let cwd =
144 std::fs::canonicalize(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
145 .unwrap_or_else(|_| PathBuf::from("/"));
146 Self {
147 profile: SandboxProfile::Workspace,
148 allow_read: vec![cwd.clone()],
149 allow_write: vec![cwd],
150 allow_network: false,
151 allow_exec: vec![],
152 env_inherit: vec![],
153 denied_domains: vec![],
154 }
155 }
156}
157
158pub(crate) use zeph_config::tools::SandboxProfile;
159
160#[non_exhaustive]
161/// Error returned when sandbox setup or policy application fails.
162#[derive(Debug, Error)]
163pub enum SandboxError {
164 /// The OS backend binary or kernel API is unavailable on this system.
165 #[error("sandbox backend unavailable: {reason}")]
166 Unavailable { reason: String },
167 /// The configured policy is not supported by the backend.
168 #[error("policy not supported by {backend}: {reason}")]
169 UnsupportedPolicy {
170 /// Backend name for diagnostics.
171 backend: &'static str,
172 /// Human-readable explanation.
173 reason: String,
174 },
175 /// I/O error during sandbox setup (e.g. temp file creation).
176 #[error("sandbox setup failed: {0}")]
177 Setup(#[from] std::io::Error),
178 /// Policy string generation failed.
179 #[error("policy generation failed: {0}")]
180 Policy(String),
181}
182
183/// Operating-system sandbox backend.
184///
185/// `wrap` is the sole entry point. Implementations rewrite a [`tokio::process::Command`]
186/// in place so that the next `.spawn()` launches inside the OS sandbox. Implementations
187/// must be fork-safe: state installed via the command builder must survive `fork()+exec()`.
188///
189/// # Contract for implementors
190///
191/// - Must not spawn the child themselves — only rewrite `cmd`.
192/// - Must not use `unsafe` code.
193/// - When the profile is [`SandboxProfile::Off`], `wrap` MUST be a no-op.
194pub trait Sandbox: Send + Sync + std::fmt::Debug {
195 /// Short identifier for logging and diagnostics (e.g., `"macos-seatbelt"`, `"linux-bwrap"`).
196 fn name(&self) -> &'static str;
197
198 /// Verify that `policy` is expressible on this backend.
199 ///
200 /// # Errors
201 ///
202 /// Returns [`SandboxError::UnsupportedPolicy`] when a required feature is missing.
203 fn supports(&self, policy: &SandboxPolicy) -> Result<(), SandboxError>;
204
205 /// Rewrite `cmd` to execute inside the OS sandbox described by `policy`.
206 ///
207 /// Called synchronously in the executor thread. Must not block on I/O for more than a few
208 /// milliseconds (temp file writes are acceptable; network calls are not).
209 ///
210 /// # Errors
211 ///
212 /// Returns [`SandboxError`] if wrapping fails (binary missing, profile generation error, etc.).
213 fn wrap(
214 &self,
215 cmd: &mut tokio::process::Command,
216 policy: &SandboxPolicy,
217 ) -> Result<(), SandboxError>;
218}
219
220/// Construct the best available [`Sandbox`] backend for the current platform.
221///
222/// Selection order:
223/// 1. macOS → `MacosSandbox`
224/// 2. Linux + `sandbox` feature → `LinuxSandbox`
225/// 3. Fallback → [`NoopSandbox`]
226///
227/// # Errors
228///
229/// Returns [`SandboxError::Unavailable`] when `strict = true` and the preferred backend
230/// is missing (e.g. `bwrap` not on `PATH`).
231pub fn build_sandbox(strict: bool) -> Result<Box<dyn Sandbox>, SandboxError> {
232 build_sandbox_with_policy(strict, false, false)
233}
234
235/// Construct the best available [`Sandbox`] backend with additional safety options.
236///
237/// Extends [`build_sandbox`] with:
238/// - `fail_if_unavailable`: when `true`, even a successful noop fallback is an error.
239/// Use this when `denied_domains` must be enforced and no effective sandbox exists.
240/// - `denied_domains_present`: when `true` and the noop backend is selected with
241/// `fail_if_unavailable = false`, emits a one-shot `WARN` that the deny list is unenforceable.
242///
243/// # Errors
244///
245/// - [`SandboxError::Unavailable`] when `strict = true` and the preferred backend binary is
246/// missing (same as [`build_sandbox`]).
247/// - [`SandboxError::Unavailable`] when `fail_if_unavailable = true` and noop would be selected.
248pub fn build_sandbox_with_policy(
249 strict: bool,
250 fail_if_unavailable: bool,
251 denied_domains_present: bool,
252) -> Result<Box<dyn Sandbox>, SandboxError> {
253 #[cfg(target_os = "macos")]
254 {
255 let _ = (strict, fail_if_unavailable, denied_domains_present);
256 Ok(Box::new(MacosSandbox::new()))
257 }
258
259 #[cfg(all(target_os = "linux", feature = "sandbox"))]
260 {
261 let _ = (fail_if_unavailable, denied_domains_present);
262 linux::LinuxSandbox::new(strict).map(|s| Box::new(s) as Box<dyn Sandbox>)
263 }
264
265 // Noop path: platform without an OS sandbox backend.
266 #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
267 {
268 if strict {
269 return Err(SandboxError::Unavailable {
270 reason: "OS sandbox not supported on this platform and strict=true".into(),
271 });
272 }
273 if fail_if_unavailable {
274 return Err(SandboxError::Unavailable {
275 reason: "noop backend selected but fail_if_unavailable=true; \
276 OS sandbox is required on this platform"
277 .into(),
278 });
279 }
280 if denied_domains_present {
281 tracing::warn!(
282 "sandbox.denied_domains is set but the OS sandbox is unavailable on this platform \
283 — denied domains cannot be enforced; set fail_if_unavailable=true to make this a \
284 startup error"
285 );
286 } else {
287 tracing::warn!(
288 "OS sandbox not supported on this platform — running without subprocess isolation"
289 );
290 }
291 Ok(Box::new(NoopSandbox))
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 #[test]
298 #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
299 fn build_sandbox_strict_fails_when_unsupported() {
300 use super::{SandboxError, build_sandbox};
301 let err = build_sandbox(true).expect_err("strict must fail on unsupported platform");
302 assert!(matches!(err, SandboxError::Unavailable { .. }));
303 }
304
305 #[test]
306 #[cfg(not(any(target_os = "macos", all(target_os = "linux", feature = "sandbox"))))]
307 fn build_sandbox_nonstrict_falls_back_to_noop() {
308 use super::build_sandbox;
309 let sb = build_sandbox(false).expect("noop fallback ok");
310 assert_eq!(sb.name(), "noop");
311 }
312
313 #[test]
314 fn canonicalize_paths_drops_nonexistent_path() {
315 use super::{SandboxPolicy, SandboxProfile};
316 use std::path::PathBuf;
317
318 let policy = SandboxPolicy {
319 profile: SandboxProfile::Workspace,
320 allow_read: vec![PathBuf::from(
321 "/this/path/does/not/exist/zeph-test-sentinel",
322 )],
323 allow_write: vec![],
324 allow_network: false,
325 allow_exec: vec![],
326 env_inherit: vec![],
327 denied_domains: vec![],
328 }
329 .canonicalized();
330
331 assert!(
332 policy.allow_read.is_empty(),
333 "non-existent path must be dropped by canonicalized()"
334 );
335 }
336}