adk_sandbox/sandbox/mod.rs
1//! OS-level sandbox enforcement types and traits.
2//!
3//! This module defines the platform-agnostic [`SandboxPolicy`] data model,
4//! the [`SandboxEnforcer`] trait for platform-specific enforcement, and the
5//! [`get_enforcer`] registry function that selects the appropriate enforcer
6//! for the current platform.
7
8#[cfg(all(feature = "sandbox-macos", target_os = "macos"))]
9pub mod macos;
10
11#[cfg(all(feature = "sandbox-linux", target_os = "linux"))]
12pub mod linux;
13
14#[cfg(all(feature = "sandbox-windows", target_os = "windows"))]
15pub mod windows;
16
17use std::collections::HashMap;
18use std::ffi::{OsStr, OsString};
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::SandboxError;
24
25/// Filesystem access mode for an allowed path.
26///
27/// # Example
28///
29/// ```rust
30/// use adk_sandbox::sandbox::AccessMode;
31///
32/// let mode = AccessMode::ReadOnly;
33/// assert_ne!(mode, AccessMode::ReadWrite);
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub enum AccessMode {
38 /// Read-only access.
39 ReadOnly,
40 /// Read and write access.
41 ReadWrite,
42}
43
44/// A filesystem path entry with an access mode.
45///
46/// # Example
47///
48/// ```rust
49/// use std::path::PathBuf;
50/// use adk_sandbox::sandbox::{AllowedPath, AccessMode};
51///
52/// let entry = AllowedPath {
53/// path: PathBuf::from("/tmp"),
54/// mode: AccessMode::ReadOnly,
55/// };
56/// assert_eq!(entry.mode, AccessMode::ReadOnly);
57/// ```
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct AllowedPath {
61 /// The filesystem path (directory or file).
62 pub path: PathBuf,
63 /// The access mode: read-only or read-write.
64 pub mode: AccessMode,
65}
66
67/// A network access rule specifying an allowed domain and ports.
68///
69/// Used for per-domain network filtering. Only enforced on platforms that
70/// support domain-level network control (macOS Seatbelt). On Linux and
71/// Windows, network access is binary (all or nothing via `allow_network`).
72///
73/// # Example
74///
75/// ```rust
76/// use adk_sandbox::sandbox::NetworkRule;
77///
78/// let rule = NetworkRule {
79/// domain: "api.openai.com".to_string(),
80/// ports: vec![443],
81/// };
82/// ```
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct NetworkRule {
86 /// The domain name to allow (e.g., "api.openai.com").
87 pub domain: String,
88 /// The ports to allow on this domain. Empty means all ports.
89 pub ports: Vec<u16>,
90}
91
92/// A declarative sandbox policy describing allowed operations.
93///
94/// Constructed via [`SandboxPolicyBuilder`]. Defaults to deny-all when
95/// no permissions are granted.
96///
97/// # Network Access
98///
99/// Network access has two levels of control:
100///
101/// 1. **Binary** (`allow_network`): When `true`, all network access is allowed.
102/// When `false`, all network is blocked. Works on all platforms.
103///
104/// 2. **Domain allowlist** (`network_rules`): When `allow_network` is `false`
105/// but `network_rules` is non-empty, only the specified domains/ports are
106/// allowed. **Only enforced on macOS** (Seatbelt supports domain-level
107/// filtering). On Linux and Windows, non-empty `network_rules` with
108/// `allow_network = false` results in all network being blocked — the
109/// rules are ignored with a `tracing::warn`.
110///
111/// # Example
112///
113/// ```rust
114/// use adk_sandbox::sandbox::SandboxPolicyBuilder;
115///
116/// // Allow only OpenAI API access
117/// let policy = SandboxPolicyBuilder::new()
118/// .allow_read("/usr/lib")
119/// .allow_domain("api.openai.com", &[443])
120/// .allow_domain("cdn.openai.com", &[443])
121/// .env("PATH", "/usr/bin")
122/// .build();
123///
124/// assert!(!policy.allow_network); // full network is denied
125/// assert_eq!(policy.network_rules.len(), 2); // but 2 domains are allowed
126/// ```
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct SandboxPolicy {
130 /// Filesystem paths the process may access.
131 pub allowed_paths: Vec<AllowedPath>,
132 /// Whether the process may access the network (all domains/ports).
133 pub allow_network: bool,
134 /// Per-domain network allowlist. Only used when `allow_network` is `false`.
135 /// Only enforced on macOS (Seatbelt). Linux/Windows ignore these rules
136 /// and fall back to binary network control.
137 #[serde(default)]
138 pub network_rules: Vec<NetworkRule>,
139 /// Whether the process may spawn child processes.
140 pub allow_process_spawn: bool,
141 /// Environment variables passed to the sandboxed process.
142 pub env: HashMap<String, String>,
143}
144
145/// The result of wrapping a command with sandbox enforcement.
146///
147/// Contains the new program to execute and the full argument list
148/// (sandbox wrapper args + original program + original args).
149#[derive(Debug, Clone)]
150pub struct WrappedCommand {
151 /// The program to execute (e.g., "sandbox-exec", "bwrap", or the original program for Windows).
152 pub program: OsString,
153 /// The full argument list including wrapper args, separator, and original args.
154 pub args: Vec<OsString>,
155}
156
157/// Builder for constructing [`SandboxPolicy`] values incrementally.
158///
159/// Defaults to deny-all: no allowed paths, no network, no process spawning,
160/// and no environment variables.
161///
162/// # Example
163///
164/// ```rust
165/// use adk_sandbox::sandbox::SandboxPolicyBuilder;
166///
167/// let policy = SandboxPolicyBuilder::new()
168/// .allow_read("/usr/lib")
169/// .allow_read_write("/tmp/work")
170/// .allow_network()
171/// .allow_process_spawn()
172/// .env("HOME", "/home/user")
173/// .build();
174///
175/// assert_eq!(policy.allowed_paths.len(), 2);
176/// assert!(policy.allow_network);
177/// assert!(policy.allow_process_spawn);
178/// assert_eq!(policy.env.get("HOME").unwrap(), "/home/user");
179/// ```
180pub struct SandboxPolicyBuilder {
181 policy: SandboxPolicy,
182}
183
184impl SandboxPolicyBuilder {
185 /// Creates a new builder with deny-all defaults.
186 pub fn new() -> Self {
187 Self {
188 policy: SandboxPolicy {
189 allowed_paths: Vec::new(),
190 allow_network: false,
191 network_rules: Vec::new(),
192 allow_process_spawn: false,
193 env: HashMap::new(),
194 },
195 }
196 }
197
198 /// Adds a read-only allowed path.
199 pub fn allow_read(mut self, path: impl Into<PathBuf>) -> Self {
200 self.policy
201 .allowed_paths
202 .push(AllowedPath { path: path.into(), mode: AccessMode::ReadOnly });
203 self
204 }
205
206 /// Adds a read-write allowed path.
207 pub fn allow_read_write(mut self, path: impl Into<PathBuf>) -> Self {
208 self.policy
209 .allowed_paths
210 .push(AllowedPath { path: path.into(), mode: AccessMode::ReadWrite });
211 self
212 }
213
214 /// Enables full network access (all domains, all ports).
215 ///
216 /// This overrides any domain-specific rules added via [`allow_domain`](Self::allow_domain).
217 pub fn allow_network(mut self) -> Self {
218 self.policy.allow_network = true;
219 self
220 }
221
222 /// Allows network access to a specific domain and ports.
223 ///
224 /// When `allow_network` is `false` (the default), only domains added via
225 /// this method are accessible. Pass an empty slice for `ports` to allow
226 /// all ports on the domain.
227 ///
228 /// **Platform support:** Only enforced on macOS (Seatbelt). On Linux and
229 /// Windows, domain-level filtering is not available — if any rules are
230 /// present but `allow_network` is false, all network is blocked.
231 ///
232 /// # Example
233 ///
234 /// ```rust
235 /// use adk_sandbox::sandbox::SandboxPolicyBuilder;
236 ///
237 /// let policy = SandboxPolicyBuilder::new()
238 /// .allow_domain("api.openai.com", &[443])
239 /// .allow_domain("huggingface.co", &[443, 80])
240 /// .build();
241 /// ```
242 pub fn allow_domain(mut self, domain: impl Into<String>, ports: &[u16]) -> Self {
243 self.policy
244 .network_rules
245 .push(NetworkRule { domain: domain.into(), ports: ports.to_vec() });
246 self
247 }
248
249 /// Enables child process spawning.
250 pub fn allow_process_spawn(mut self) -> Self {
251 self.policy.allow_process_spawn = true;
252 self
253 }
254
255 /// Adds an environment variable key-value pair.
256 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
257 self.policy.env.insert(key.into(), value.into());
258 self
259 }
260
261 /// Consumes the builder and returns the constructed [`SandboxPolicy`].
262 pub fn build(self) -> SandboxPolicy {
263 self.policy
264 }
265}
266
267impl Default for SandboxPolicyBuilder {
268 fn default() -> Self {
269 Self::new()
270 }
271}
272
273/// Platform-specific sandbox enforcement.
274///
275/// Implementations translate a [`SandboxPolicy`] into OS-native restrictions.
276/// The trait uses a `wrap_command` approach rather than mutating a `Command`
277/// directly, because `tokio::process::Command` does not allow replacing the
278/// program after construction.
279///
280/// # Integration with ProcessBackend
281///
282/// `ProcessBackend::run_command()` calls `wrap_command()` to obtain the
283/// wrapper program and args, then constructs a new `Command` with those
284/// values. This avoids the limitation that tokio's Command doesn't expose
285/// `get_program()`/`get_args()` setters after creation.
286///
287/// # Windows Exception
288///
289/// On Windows, `WindowsEnforcer` does NOT wrap the command — it configures
290/// the process token via Win32 APIs. Its `wrap_command` returns the original
291/// program and args unchanged, and `configure_command` applies the
292/// AppContainer restrictions via `Command::creation_flags()` and
293/// pre-spawn setup.
294pub trait SandboxEnforcer: Send + Sync {
295 /// Returns the enforcer name (e.g., "seatbelt", "bubblewrap", "appcontainer").
296 fn name(&self) -> &str;
297
298 /// Checks whether the enforcer is functional on the current system.
299 fn probe(&self) -> Result<(), SandboxError>;
300
301 /// Wraps the original command with sandbox enforcement.
302 ///
303 /// Given the original program and its arguments, returns a [`WrappedCommand`]
304 /// containing the sandbox wrapper program and the full argument list.
305 ///
306 /// This method:
307 /// 1. Canonicalizes all paths in the policy (logs `tracing::warn` if changed)
308 /// 2. Returns `SandboxError::PolicyViolation` if any path cannot be resolved
309 /// 3. Generates the platform-specific wrapper (Seatbelt profile, bwrap args, etc.)
310 /// 4. Returns the wrapped program and args
311 fn wrap_command(
312 &self,
313 program: &OsStr,
314 args: &[OsString],
315 policy: &SandboxPolicy,
316 ) -> Result<WrappedCommand, SandboxError>;
317
318 /// Optional: configure the Command with platform-specific process attributes.
319 ///
320 /// Called after the Command is constructed from `wrap_command()` output.
321 /// Default implementation is a no-op. Windows uses this to set
322 /// AppContainer process attributes via `creation_flags()` and
323 /// `raw_attribute()`.
324 fn configure_command(
325 &self,
326 _cmd: &mut tokio::process::Command,
327 _policy: &SandboxPolicy,
328 ) -> Result<(), SandboxError> {
329 Ok(())
330 }
331}
332
333/// Returns the platform-appropriate sandbox enforcer.
334///
335/// Selects the enforcer based on enabled feature flags, then calls `probe()`
336/// to verify it is functional. Returns an error if no enforcer is available
337/// or if the probe fails.
338///
339/// # Errors
340///
341/// Returns `SandboxError::EnforcerUnavailable` if no sandbox feature flag is
342/// enabled for the current platform, or if the selected enforcer's `probe()`
343/// check fails.
344///
345/// # Example
346///
347/// ```rust,ignore
348/// use adk_sandbox::sandbox::get_enforcer;
349///
350/// let enforcer = get_enforcer()?;
351/// println!("Using enforcer: {}", enforcer.name());
352/// ```
353pub fn get_enforcer() -> Result<Box<dyn SandboxEnforcer>, SandboxError> {
354 #[cfg(all(feature = "sandbox-macos", target_os = "macos"))]
355 {
356 let enforcer = macos::MacOsEnforcer::new();
357 enforcer.probe()?;
358 return Ok(Box::new(enforcer));
359 }
360
361 #[cfg(all(feature = "sandbox-linux", target_os = "linux"))]
362 {
363 let enforcer = linux::LinuxEnforcer::new();
364 enforcer.probe()?;
365 return Ok(Box::new(enforcer));
366 }
367
368 #[cfg(all(feature = "sandbox-windows", target_os = "windows"))]
369 {
370 let enforcer = windows::WindowsEnforcer::new();
371 enforcer.probe()?;
372 return Ok(Box::new(enforcer));
373 }
374
375 #[allow(unreachable_code)]
376 Err(SandboxError::EnforcerUnavailable {
377 enforcer: "none".to_string(),
378 message: "no sandbox feature flag is enabled for this platform".to_string(),
379 })
380}