Skip to main content

agent_code_lib/sandbox/
mod.rs

1//! Process-level sandboxing for subprocess-spawning tools.
2//!
3//! Sandboxing wraps [`tokio::process::Command`] with an OS-level isolation
4//! mechanism before the child is spawned. The current permission system is
5//! policy enforced *inside* the agent process — a compromised tool or a
6//! prompt-injection attack gets the agent's full privileges. Sandboxing
7//! adds a second layer of defense so that even a compromised subprocess
8//! cannot write outside the project directory or read credentials.
9//!
10//! # Platform support
11//!
12//! This first slice ships **macOS only** using `sandbox-exec` (Seatbelt).
13//! Linux `bwrap` and Windows Low Integrity strategies will land as
14//! follow-up PRs behind the same [`SandboxStrategy`] trait.
15//!
16//! # Wiring
17//!
18//! A [`SandboxExecutor`] is built once per session from
19//! [`crate::config::schema::SandboxConfig`] and threaded into
20//! [`crate::tools::ToolContext`]. Subprocess-spawning tools (currently:
21//! [`Bash`](crate::tools::Bash)) call [`SandboxExecutor::wrap`] on their
22//! `Command` before `.spawn()`. When sandboxing is disabled or the
23//! platform has no strategy, `wrap` returns the command unchanged.
24
25pub mod policy;
26pub mod seatbelt;
27
28pub use policy::SandboxPolicy;
29
30use std::path::{Path, PathBuf};
31use std::process::Stdio;
32use std::sync::Arc;
33
34use tokio::process::Command;
35use tracing::{debug, warn};
36
37use crate::config::SandboxConfig;
38
39/// Strategy trait for wrapping a subprocess command with OS-level isolation.
40pub trait SandboxStrategy: Send + Sync {
41    /// Short name for diagnostics and logging (e.g. `"seatbelt"`, `"noop"`).
42    fn name(&self) -> &'static str;
43
44    /// Wrap `cmd` so the spawned child runs inside the sandbox.
45    ///
46    /// Implementations may build a new [`Command`] that invokes a helper
47    /// (e.g. `sandbox-exec`) with the original program as its child. The
48    /// returned command must preserve the original working directory and
49    /// environment but does **not** need to re-apply stdio — the caller
50    /// re-applies piped stdio via [`SandboxExecutor::wrap`].
51    fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command;
52}
53
54/// No-op strategy used when sandboxing is disabled or unavailable.
55pub struct NoopStrategy;
56
57impl SandboxStrategy for NoopStrategy {
58    fn name(&self) -> &'static str {
59        "noop"
60    }
61
62    fn wrap_command(&self, cmd: Command, _policy: &SandboxPolicy) -> Command {
63        cmd
64    }
65}
66
67/// Owns the active strategy and resolved policy for a session.
68///
69/// Construct one at session start via [`SandboxExecutor::from_config`] and
70/// thread it into [`crate::tools::ToolContext::sandbox`]. Tools call
71/// [`SandboxExecutor::wrap`] before spawning.
72pub struct SandboxExecutor {
73    strategy: Arc<dyn SandboxStrategy>,
74    policy: SandboxPolicy,
75    enabled: bool,
76    /// Whether per-tool-call bypass (e.g. the `dangerouslyDisableSandbox`
77    /// Bash tool parameter) is permitted. Derived from
78    /// `security.disable_bypass_permissions == false`.
79    allow_bypass: bool,
80}
81
82impl SandboxExecutor {
83    /// Build an executor from config and the session's project directory.
84    ///
85    /// If `config.enabled` is false, the returned executor's [`wrap`] is a
86    /// no-op. If the selected strategy is unavailable on the current
87    /// platform (e.g. `seatbelt` on Linux), falls back to [`NoopStrategy`]
88    /// and logs a warning — the caller should still treat this as enabled
89    /// for `/sandbox` reporting so the degradation is visible.
90    pub fn from_config(config: &SandboxConfig, project_dir: &Path) -> Self {
91        Self::from_config_with_bypass(config, project_dir, true)
92    }
93
94    /// Build an executor, explicitly setting whether per-call bypass is allowed.
95    ///
96    /// Call sites with access to the full [`crate::config::Config`] should
97    /// prefer [`SandboxExecutor::from_session_config`], which reads the
98    /// bypass flag from `security.disable_bypass_permissions`.
99    pub fn from_config_with_bypass(
100        config: &SandboxConfig,
101        project_dir: &Path,
102        allow_bypass: bool,
103    ) -> Self {
104        let policy = SandboxPolicy::from_config(config, project_dir);
105        let strategy = pick_strategy(&config.strategy);
106
107        if config.enabled && strategy.name() == "noop" {
108            warn!(
109                "sandbox enabled in config but no working strategy on this platform; \
110                 running without OS-level isolation"
111            );
112        }
113
114        Self {
115            strategy,
116            policy,
117            enabled: config.enabled,
118            allow_bypass,
119        }
120    }
121
122    /// Build an executor from the top-level [`crate::config::Config`],
123    /// honoring the enterprise `security.disable_bypass_permissions` flag.
124    pub fn from_session_config(config: &crate::config::Config, project_dir: &Path) -> Self {
125        Self::from_config_with_bypass(
126            &config.sandbox,
127            project_dir,
128            !config.security.disable_bypass_permissions,
129        )
130    }
131
132    /// Strategy name for diagnostics (e.g. `/sandbox` command output).
133    pub fn strategy_name(&self) -> &'static str {
134        self.strategy.name()
135    }
136
137    /// Whether sandboxing is active (config enabled *and* a real strategy is selected).
138    pub fn is_active(&self) -> bool {
139        self.enabled && self.strategy.name() != "noop"
140    }
141
142    /// Access the resolved policy for diagnostics.
143    pub fn policy(&self) -> &SandboxPolicy {
144        &self.policy
145    }
146
147    /// Whether a tool call may request per-call bypass (e.g. the Bash tool's
148    /// `dangerouslyDisableSandbox` parameter).
149    ///
150    /// Returns `false` when `security.disable_bypass_permissions = true`.
151    pub fn allow_bypass(&self) -> bool {
152        self.allow_bypass
153    }
154
155    /// Wrap `cmd` with the active strategy, reapplying piped stdio.
156    ///
157    /// If the executor is disabled or the strategy is a no-op, returns
158    /// `cmd` unchanged. Callers should use this before `.spawn()`.
159    pub fn wrap(&self, cmd: Command) -> Command {
160        if !self.is_active() {
161            return cmd;
162        }
163        debug!(
164            strategy = self.strategy.name(),
165            project_dir = %self.policy.project_dir.display(),
166            "wrapping subprocess with sandbox"
167        );
168        let mut wrapped = self.strategy.wrap_command(cmd, &self.policy);
169        // Strategies do not re-apply stdio (tokio hides it); force piped
170        // stdio so the caller can still read stdout/stderr of the wrapped
171        // child process.
172        wrapped
173            .stdout(Stdio::piped())
174            .stderr(Stdio::piped())
175            .stdin(Stdio::null());
176        wrapped
177    }
178
179    /// A "disabled" executor for tests and default construction.
180    pub fn disabled() -> Self {
181        Self {
182            strategy: Arc::new(NoopStrategy),
183            policy: SandboxPolicy {
184                project_dir: PathBuf::from("."),
185                allowed_write_paths: Vec::new(),
186                forbidden_paths: Vec::new(),
187                allow_network: true,
188            },
189            enabled: false,
190            allow_bypass: true,
191        }
192    }
193}
194
195fn pick_strategy(requested: &str) -> Arc<dyn SandboxStrategy> {
196    match requested {
197        "none" => Arc::new(NoopStrategy),
198        "seatbelt" => make_seatbelt_or_noop(),
199        "auto" | "" => auto_detect(),
200        other => {
201            warn!("unknown sandbox strategy {other:?}; falling back to noop");
202            Arc::new(NoopStrategy)
203        }
204    }
205}
206
207fn auto_detect() -> Arc<dyn SandboxStrategy> {
208    if cfg!(target_os = "macos") {
209        make_seatbelt_or_noop()
210    } else {
211        Arc::new(NoopStrategy)
212    }
213}
214
215fn make_seatbelt_or_noop() -> Arc<dyn SandboxStrategy> {
216    if cfg!(target_os = "macos") && sandbox_exec_available() {
217        Arc::new(seatbelt::SeatbeltStrategy)
218    } else {
219        Arc::new(NoopStrategy)
220    }
221}
222
223/// True if `sandbox-exec` is resolvable on `$PATH`.
224fn sandbox_exec_available() -> bool {
225    // Probe via `which`-style $PATH walk. We avoid `std::process::Command`
226    // here to keep this synchronous and not spawn a child at detect time.
227    let Some(path) = std::env::var_os("PATH") else {
228        return false;
229    };
230    for dir in std::env::split_paths(&path) {
231        if dir.join("sandbox-exec").is_file() {
232            return true;
233        }
234    }
235    false
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn sample_config(enabled: bool, strategy: &str) -> SandboxConfig {
243        SandboxConfig {
244            enabled,
245            strategy: strategy.to_string(),
246            allowed_write_paths: vec![],
247            forbidden_paths: vec![],
248            allow_network: false,
249        }
250    }
251
252    #[test]
253    fn pick_strategy_none_is_noop() {
254        assert_eq!(pick_strategy("none").name(), "noop");
255    }
256
257    #[test]
258    fn pick_strategy_empty_is_auto() {
259        // Empty string should behave the same as "auto".
260        assert_eq!(pick_strategy("").name(), auto_detect().name());
261    }
262
263    #[test]
264    fn pick_strategy_auto_matches_auto_detect() {
265        assert_eq!(pick_strategy("auto").name(), auto_detect().name());
266    }
267
268    #[test]
269    fn pick_strategy_unknown_is_noop() {
270        assert_eq!(pick_strategy("martian").name(), "noop");
271    }
272
273    #[test]
274    #[cfg(not(target_os = "macos"))]
275    fn auto_detect_off_macos_is_noop() {
276        assert_eq!(auto_detect().name(), "noop");
277    }
278
279    #[test]
280    #[cfg(target_os = "macos")]
281    fn auto_detect_on_macos_picks_seatbelt() {
282        // macOS CI and dev machines always have sandbox-exec on $PATH.
283        assert_eq!(auto_detect().name(), "seatbelt");
284    }
285
286    #[test]
287    #[cfg(target_os = "macos")]
288    fn pick_strategy_seatbelt_matches_make_seatbelt() {
289        assert_eq!(pick_strategy("seatbelt").name(), "seatbelt");
290    }
291
292    #[test]
293    #[cfg(not(target_os = "macos"))]
294    fn pick_strategy_seatbelt_off_macos_is_noop() {
295        // Selecting seatbelt on Linux should silently degrade to noop
296        // rather than crashing at config-load time.
297        assert_eq!(pick_strategy("seatbelt").name(), "noop");
298    }
299
300    #[test]
301    fn disabled_executor_is_inactive() {
302        let exec = SandboxExecutor::disabled();
303        assert!(!exec.is_active());
304        assert_eq!(exec.strategy_name(), "noop");
305        assert!(exec.allow_bypass());
306    }
307
308    #[test]
309    fn disabled_executor_wrap_is_identity_program() {
310        // We cannot compare Commands directly, but we can verify that
311        // wrapping an echo command still targets echo (not sandbox-exec)
312        // when the executor is disabled.
313        let exec = SandboxExecutor::disabled();
314        let cmd = Command::new("echo");
315        let wrapped = exec.wrap(cmd);
316        let program = wrapped.as_std().get_program();
317        assert_eq!(program, "echo");
318    }
319
320    #[test]
321    fn from_config_disabled_is_inactive_regardless_of_strategy() {
322        let cfg = sample_config(false, "seatbelt");
323        let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
324        assert!(!exec.is_active());
325    }
326
327    #[test]
328    fn from_config_strategy_none_is_inactive_even_when_enabled() {
329        let cfg = sample_config(true, "none");
330        let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
331        assert!(!exec.is_active());
332        assert_eq!(exec.strategy_name(), "noop");
333    }
334
335    #[test]
336    fn from_config_policy_contains_project_dir() {
337        let cfg = sample_config(false, "auto");
338        let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/work/repo"));
339        assert_eq!(exec.policy().project_dir, PathBuf::from("/work/repo"));
340    }
341
342    #[test]
343    fn from_config_with_bypass_respects_flag() {
344        let cfg = sample_config(true, "auto");
345        let allowed =
346            SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), true);
347        let denied =
348            SandboxExecutor::from_config_with_bypass(&cfg, std::path::Path::new("/tmp"), false);
349        assert!(allowed.allow_bypass());
350        assert!(!denied.allow_bypass());
351    }
352
353    #[test]
354    fn from_session_config_honors_disable_bypass_permissions() {
355        let base = crate::config::Config {
356            sandbox: sample_config(true, "none"),
357            ..Default::default()
358        };
359
360        let mut denied_cfg = base.clone();
361        denied_cfg.security.disable_bypass_permissions = true;
362        let denied =
363            SandboxExecutor::from_session_config(&denied_cfg, std::path::Path::new("/tmp"));
364        assert!(!denied.allow_bypass());
365
366        let mut allowed_cfg = base;
367        allowed_cfg.security.disable_bypass_permissions = false;
368        let allowed =
369            SandboxExecutor::from_session_config(&allowed_cfg, std::path::Path::new("/tmp"));
370        assert!(allowed.allow_bypass());
371    }
372
373    #[test]
374    #[cfg(target_os = "macos")]
375    fn active_seatbelt_wrap_replaces_program() {
376        let cfg = sample_config(true, "seatbelt");
377        let exec = SandboxExecutor::from_config(&cfg, std::path::Path::new("/tmp"));
378        if !exec.is_active() {
379            eprintln!("skipping: seatbelt unavailable");
380            return;
381        }
382        let wrapped = exec.wrap(Command::new("echo"));
383        let std_cmd = wrapped.as_std();
384        assert_eq!(std_cmd.get_program(), "sandbox-exec");
385        // After `-p <profile>`, the original program is argv[3].
386        let args: Vec<_> = std_cmd.get_args().collect();
387        assert_eq!(args.first().map(|a| a.to_str().unwrap()), Some("-p"));
388        // "echo" appears after the profile string.
389        assert!(args.iter().any(|a| a.to_str() == Some("echo")));
390    }
391
392    #[test]
393    fn noop_strategy_returns_command_untouched() {
394        // Guard against accidental NoopStrategy behavior changes.
395        let cmd = Command::new("cat");
396        let policy = SandboxPolicy {
397            project_dir: PathBuf::from("/tmp"),
398            allowed_write_paths: vec![],
399            forbidden_paths: vec![],
400            allow_network: false,
401        };
402        let wrapped = NoopStrategy.wrap_command(cmd, &policy);
403        assert_eq!(wrapped.as_std().get_program(), "cat");
404    }
405}