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}