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