Skip to main content

hanzo_sandbox/
lib.rs

1//! OS-level sandbox for subprocesses spawned on behalf of LLMs.
2//!
3//! Linux uses env scrub, namespaces, Landlock, rlimits, seccomp, and optional
4//! cgroup v2 limits. macOS uses env scrub and Seatbelt.
5//!
6//! ```ignore
7//! use hanzo_sandbox::{detect, SandboxPolicy};
8//! let sandbox = detect();
9//! let policy = SandboxPolicy::default();
10//! sandbox.harden(&mut cmd, &policy)?;
11//! ```
12
13mod null;
14
15#[cfg(any(target_os = "linux", target_os = "macos"))]
16mod env_scrub;
17
18#[cfg(target_os = "linux")]
19mod linux;
20
21#[cfg(target_os = "macos")]
22mod macos;
23
24#[cfg(all(test, not(target_os = "macos")))]
25#[path = "macos/profile.rs"]
26mod macos_profile;
27
28use std::path::PathBuf;
29
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33pub use null::NullSandbox;
34
35/// Environment variable that overrides the configured sandbox mode at runtime.
36/// Accepted values: `auto`, `on`, `off`. Case-insensitive.
37pub const SANDBOX_ENV_VAR: &str = "HANZO_SANDBOX";
38pub const DEFAULT_MAX_MEMORY_MB: u64 = 2048;
39pub const DEFAULT_MAX_CPU_SECS: u64 = 300;
40pub const DEFAULT_MAX_PROCS: u32 = 64;
41pub const DEFAULT_MAX_OPEN_FDS: u32 = 1024;
42pub const DEFAULT_MAX_FILE_SZ_MB: u64 = 256;
43
44#[derive(Debug, Error)]
45pub enum SandboxError {
46    #[error("sandbox setup failed: {0}")]
47    Setup(String),
48    #[error("sandbox not supported on this platform")]
49    Unsupported,
50    #[error(transparent)]
51    Io(#[from] std::io::Error),
52}
53
54/// Network access permitted to sandboxed processes.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum NetworkMode {
58    /// No sockets at all (denied at syscall level on Linux).
59    None,
60    /// Loopback only. Default - many libs probe 127.0.0.1 on import.
61    #[default]
62    Loopback,
63    /// Unrestricted - matches pre-sandbox behavior.
64    Full,
65}
66
67/// Policy applied to a sandboxed process.
68#[derive(Clone, Debug, Serialize, Deserialize)]
69pub struct SandboxPolicy {
70    /// Address-space cap in MB where resource limits are supported.
71    pub max_memory_mb: u64,
72    /// CPU-time cap in seconds where resource limits are supported.
73    pub max_cpu_secs: u64,
74    /// Child process cap where resource limits are supported.
75    pub max_procs: u32,
76    /// Open file descriptor cap where resource limits are supported.
77    pub max_open_fds: u32,
78    /// Maximum file size in MB where resource limits are supported.
79    pub max_file_sz_mb: u64,
80    /// Network access granted to sandboxed subprocesses.
81    pub network: NetworkMode,
82    /// Additional filesystem paths the sandboxed process may read.
83    /// Appended to the built-in system allowlist.
84    #[serde(default)]
85    pub extra_fs_read: Vec<PathBuf>,
86    /// Additional filesystem paths the sandboxed process may read and write.
87    /// Appended to the per-session workdir.
88    #[serde(default)]
89    pub extra_fs_write: Vec<PathBuf>,
90    /// Additional environment variable names allowed through the env scrub.
91    /// Appended to the built-in allowlist (PATH, LANG, ...). Names only,
92    /// values come from the parent process's environment.
93    #[serde(default)]
94    pub extra_env: Vec<String>,
95    /// When true, missing requested layers become hard errors.
96    #[serde(default)]
97    pub strict: bool,
98    /// Per-session writable directory. Filled in by the caller right before
99    /// `harden()`.
100    pub session_workdir: Option<PathBuf>,
101}
102
103impl Default for SandboxPolicy {
104    fn default() -> Self {
105        Self {
106            max_memory_mb: DEFAULT_MAX_MEMORY_MB,
107            max_cpu_secs: DEFAULT_MAX_CPU_SECS,
108            max_procs: DEFAULT_MAX_PROCS,
109            max_open_fds: DEFAULT_MAX_OPEN_FDS,
110            max_file_sz_mb: DEFAULT_MAX_FILE_SZ_MB,
111            network: NetworkMode::Loopback,
112            extra_fs_read: Vec::new(),
113            extra_fs_write: Vec::new(),
114            extra_env: Vec::new(),
115            strict: false,
116            session_workdir: None,
117        }
118    }
119}
120
121/// What a `Sandbox` can enforce for a given policy after OS feature detection.
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
123pub struct EffectiveProtection {
124    /// Filesystem reads/writes outside the allowlist are denied at the OS
125    /// level (Landlock on Linux, Seatbelt on macOS). False on NullSandbox
126    /// or when Landlock is unavailable.
127    pub fs_isolated: bool,
128    /// Network access is restricted (`network=none` blocks socket(); netns
129    /// scopes routing for `loopback`; Seatbelt denies non-local for macOS).
130    /// False on NullSandbox or when network=Full.
131    pub network_isolated: bool,
132    /// Resource limits will be applied. On Linux this also means the seccomp
133    /// deny-list will be installed. False on macOS and NullSandbox.
134    pub rlimits_applied: bool,
135}
136
137impl EffectiveProtection {
138    /// True if any layer is actually enforcing something.
139    pub fn any(&self) -> bool {
140        self.fs_isolated || self.network_isolated || self.rlimits_applied
141    }
142}
143
144/// Applied to a `tokio::process::Command` before spawn and to the resulting
145/// PID after spawn. Implementations are platform-specific.
146pub trait Sandbox: Send + Sync {
147    /// Harden the command. Called before `spawn()`. May install `pre_exec`
148    /// hooks, clear env, set up wrapping argv, etc.
149    fn harden(
150        &self,
151        cmd: &mut tokio::process::Command,
152        policy: &SandboxPolicy,
153    ) -> Result<(), SandboxError>;
154
155    /// Post-spawn attachment. Called after `spawn()` returns. Used for
156    /// cgroup membership writes on Linux; no-op elsewhere.
157    fn attach(&self, _pid: u32, _policy: &SandboxPolicy) -> Result<(), SandboxError> {
158        Ok(())
159    }
160
161    /// Human-readable name for logging.
162    fn name(&self) -> &'static str;
163
164    /// Probe the OS once and report what layers will actually fire for the
165    /// given policy. Implementations should be cheap and side-effect-free.
166    fn effective(&self, policy: &SandboxPolicy) -> EffectiveProtection;
167}
168
169/// Return the best sandbox implementation for the current platform.
170pub fn detect() -> Box<dyn Sandbox> {
171    #[cfg(target_os = "linux")]
172    {
173        Box::new(linux::LinuxSandbox::new())
174    }
175    #[cfg(target_os = "macos")]
176    {
177        Box::new(macos::MacosSandbox::new())
178    }
179    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
180    {
181        tracing::warn!(
182            "no sandbox implementation for this platform - model-generated code has full host access"
183        );
184        Box::new(NullSandbox)
185    }
186}
187
188/// Explicit no-op sandbox.
189pub fn null() -> Box<dyn Sandbox> {
190    static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
191    WARNED.get_or_init(|| {
192        tracing::warn!(
193            "sandbox disabled - model-generated code has full filesystem, network, and subprocess access"
194        );
195    });
196    Box::new(NullSandbox)
197}