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}