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