agent_code_lib/sandbox/
mod.rs1pub 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
39pub trait SandboxStrategy: Send + Sync {
41 fn name(&self) -> &'static str;
43
44 fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command;
52}
53
54pub 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
67pub struct SandboxExecutor {
73 strategy: Arc<dyn SandboxStrategy>,
74 policy: SandboxPolicy,
75 enabled: bool,
76 allow_bypass: bool,
80}
81
82impl SandboxExecutor {
83 pub fn from_config(config: &SandboxConfig, project_dir: &Path) -> Self {
91 Self::from_config_with_bypass(config, project_dir, true)
92 }
93
94 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 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 pub fn strategy_name(&self) -> &'static str {
134 self.strategy.name()
135 }
136
137 pub fn is_active(&self) -> bool {
139 self.enabled && self.strategy.name() != "noop"
140 }
141
142 pub fn policy(&self) -> &SandboxPolicy {
144 &self.policy
145 }
146
147 pub fn allow_bypass(&self) -> bool {
152 self.allow_bypass
153 }
154
155 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 wrapped
173 .stdout(Stdio::piped())
174 .stderr(Stdio::piped())
175 .stdin(Stdio::null());
176 wrapped
177 }
178
179 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
223fn sandbox_exec_available() -> bool {
225 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 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 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 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 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 let args: Vec<_> = std_cmd.get_args().collect();
387 assert_eq!(args.first().map(|a| a.to_str().unwrap()), Some("-p"));
388 assert!(args.iter().any(|a| a.to_str() == Some("echo")));
390 }
391
392 #[test]
393 fn noop_strategy_returns_command_untouched() {
394 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}