Skip to main content

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}