astrid_workspace/sandbox/
mod.rs1use std::ffi::OsString;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[cfg(target_os = "linux")]
7mod bwrap;
8#[cfg(target_os = "macos")]
9mod seatbelt;
10
11fn validate_sandbox_str<'a>(path: &'a Path, label: &str) -> io::Result<&'a str> {
16 if !path.is_absolute() {
17 return Err(io::Error::new(
18 io::ErrorKind::InvalidInput,
19 format!(
20 "sandbox {label} must be an absolute path, got: {}",
21 path.display()
22 ),
23 ));
24 }
25 let s = path.to_str().ok_or_else(|| {
26 io::Error::new(
27 io::ErrorKind::InvalidInput,
28 format!("sandbox {label} is not valid UTF-8: {}", path.display()),
29 )
30 })?;
31 if s.contains(['"', '\\', '\0']) {
32 return Err(io::Error::new(
33 io::ErrorKind::InvalidInput,
34 format!(
35 "sandbox {label} contains forbidden characters (double-quote, backslash, or null): {}",
36 path.display()
37 ),
38 ));
39 }
40 Ok(s)
41}
42
43pub struct SandboxCommand;
48
49impl SandboxCommand {
50 #[allow(clippy::needless_pass_by_value)] pub fn wrap(inner_cmd: Command, worktree_path: &Path) -> io::Result<Command> {
70 let _ = validate_sandbox_str(worktree_path, "worktree path")?;
75
76 #[cfg(target_os = "linux")]
77 {
78 let mut bwrap = Command::new("bwrap");
81 bwrap
82 .arg("--ro-bind").arg("/").arg("/") .arg("--dev").arg("/dev") .arg("--proc").arg("/proc") .arg("--bind").arg(worktree_path).arg(worktree_path) .arg("--tmpfs").arg("/tmp") .arg("--unshare-all") .arg("--share-net") .arg("--die-with-parent"); bwrap.arg(inner_cmd.get_program());
93 for arg in inner_cmd.get_args() {
94 bwrap.arg(arg);
95 }
96
97 for (k, v) in inner_cmd.get_envs() {
99 if let Some(v) = v {
100 bwrap.env(k, v);
101 } else {
102 bwrap.env_remove(k);
103 }
104 }
105 if let Some(dir) = inner_cmd.get_current_dir() {
106 bwrap.current_dir(dir);
107 }
108
109 Ok(bwrap)
110 }
111
112 #[cfg(target_os = "macos")]
113 {
114 if seatbelt::darwin_major_version() >= 24 {
116 tracing::warn!(
117 "macOS 15+ detected: sandbox-exec is deprecated. Running host process unsandboxed."
118 );
119 return Ok(inner_cmd);
120 }
121
122 let worktree_str = worktree_path
124 .to_str()
125 .expect("unreachable: validated UTF-8 above");
126
127 let profile = format!(
131 r#"(version 1)
132(deny default)
133(allow process-exec*)
134(allow process-fork)
135(allow network*)
136(allow sysctl-read)
137(allow ipc-posix-shm)
138(allow file-read*
139 (subpath "/usr")
140 (subpath "/bin")
141 (subpath "/sbin")
142 (subpath "/System")
143 (subpath "/Library")
144 (subpath "/opt")
145 (subpath "/dev")
146 (subpath "{worktree_str}")
147 (subpath "/private/tmp")
148 (subpath "/var/folders")
149)
150(allow file-write*
151 (subpath "{worktree_str}")
152 (subpath "/private/tmp")
153 (subpath "/var/folders")
154 (literal "/dev/null")
155)"#
156 );
157
158 let mut sb_cmd = Command::new("sandbox-exec");
160 sb_cmd.arg("-p").arg(&profile);
161
162 sb_cmd.arg(inner_cmd.get_program());
164 for arg in inner_cmd.get_args() {
165 sb_cmd.arg(arg);
166 }
167
168 for (k, v) in inner_cmd.get_envs() {
170 if let Some(v) = v {
171 sb_cmd.env(k, v);
172 } else {
173 sb_cmd.env_remove(k);
174 }
175 }
176 if let Some(dir) = inner_cmd.get_current_dir() {
177 sb_cmd.current_dir(dir);
178 }
179
180 Ok(sb_cmd)
181 }
182
183 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
184 {
185 tracing::warn!(
186 "Host-level sandboxing is not supported on this OS. Processes will run unsandboxed."
187 );
188 Ok(inner_cmd)
189 }
190 }
191}
192
193#[derive(Debug, Clone)]
197pub struct SandboxPrefix {
198 pub program: OsString,
200 pub args: Vec<OsString>,
202}
203
204#[cfg(target_os = "linux")]
229fn linux_unavailable_hint() -> &'static str {
230 "On Ubuntu 24.04+, this is most often caused by \
231 `kernel.apparmor_restrict_unprivileged_userns=1`. \
232 Fix with: `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` \
233 (or persist via /etc/sysctl.d/). On other distros, ensure the \
234 `bubblewrap` package is installed."
235}
236
237#[cfg(not(any(target_os = "linux", target_os = "macos")))]
240fn unsupported_os_hint() -> &'static str {
241 "Astrid currently supports OS-level sandboxing on Linux (bwrap) and \
242 macOS (Seatbelt). On other platforms there is no sandbox layer \
243 available — native subprocess capsules cannot be safely contained."
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
262pub enum SandboxPolicy {
263 #[default]
270 Required,
271 Off,
278}
279
280impl SandboxPolicy {
281 #[must_use]
287 pub fn parse(s: &str) -> Option<Self> {
288 match s.trim().to_ascii_lowercase().as_str() {
289 "required" => Some(Self::Required),
290 "off" => Some(Self::Off),
291 _ => None,
292 }
293 }
294
295 #[must_use]
303 pub fn from_env() -> Self {
304 match std::env::var("ASTRID_SANDBOX_POLICY") {
305 Ok(s) => {
306 if let Some(p) = Self::parse(&s) {
307 p
308 } else {
309 tracing::warn!(
310 value = %s,
311 "ASTRID_SANDBOX_POLICY value is not one of \
312 required / off — falling back to `required`"
313 );
314 Self::default()
315 }
316 },
317 Err(_) => Self::default(),
318 }
319 }
320}
321
322#[derive(Debug, Clone)]
330pub struct ProcessSandboxConfig {
331 writable_root: PathBuf,
333 extra_read_paths: Vec<PathBuf>,
335 extra_write_paths: Vec<PathBuf>,
337 allow_network: bool,
339 hidden_paths: Vec<PathBuf>,
341 policy: SandboxPolicy,
343}
344
345impl ProcessSandboxConfig {
346 #[must_use]
354 pub fn new(writable_root: impl Into<PathBuf>) -> Self {
355 Self {
356 writable_root: writable_root.into(),
357 extra_read_paths: Vec::new(),
358 extra_write_paths: Vec::new(),
359 allow_network: true,
360 hidden_paths: Vec::new(),
361 policy: SandboxPolicy::from_env(),
362 }
363 }
364
365 #[must_use]
368 pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
369 self.policy = policy;
370 self
371 }
372
373 #[must_use]
375 pub fn with_network(mut self, allow: bool) -> Self {
376 self.allow_network = allow;
377 self
378 }
379
380 #[must_use]
382 pub fn with_extra_read(mut self, path: impl Into<PathBuf>) -> Self {
383 self.extra_read_paths.push(path.into());
384 self
385 }
386
387 #[must_use]
389 pub fn with_extra_write(mut self, path: impl Into<PathBuf>) -> Self {
390 self.extra_write_paths.push(path.into());
391 self
392 }
393
394 #[must_use]
399 pub fn with_hidden(mut self, path: impl Into<PathBuf>) -> Self {
400 self.hidden_paths.push(path.into());
401 self
402 }
403
404 pub fn sandbox_prefix(&self) -> io::Result<Option<SandboxPrefix>> {
428 self.validate_all_paths()?;
433
434 if self.policy == SandboxPolicy::Off {
438 return Ok(None);
439 }
440
441 #[cfg(target_os = "linux")]
442 {
443 if bwrap::bwrap_available() {
444 return Ok(Some(self.build_bwrap_prefix()));
445 }
446 self.handle_unavailable_sandbox(linux_unavailable_hint())
447 }
448
449 #[cfg(target_os = "macos")]
450 {
451 self.build_seatbelt_prefix().map(Some)
456 }
457
458 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
459 {
460 self.handle_unavailable_sandbox(unsupported_os_hint())
461 }
462 }
463
464 #[cfg(any(
468 target_os = "linux",
469 not(any(target_os = "linux", target_os = "macos"))
470 ))]
471 fn handle_unavailable_sandbox(&self, hint: &str) -> io::Result<Option<SandboxPrefix>> {
472 match self.policy {
473 SandboxPolicy::Required => Err(io::Error::other(format!(
474 "OS-level sandbox unavailable and policy is `required` — \
475 refusing to launch native subprocess capsule without \
476 containment. {hint} To run without the sandbox anyway \
477 (trusted dev environments, CI runners where the kernel \
478 can't be configured), set `ASTRID_SANDBOX_POLICY=off`. \
479 The `required` default exists to keep the security \
480 guarantee documented in the README — see issue #655."
481 ))),
482 SandboxPolicy::Off => Ok(None),
484 }
485 }
486
487 fn validate_all_paths(&self) -> io::Result<()> {
489 validate_sandbox_str(&self.writable_root, "writable root")?;
490 for p in &self.extra_read_paths {
491 validate_sandbox_str(p, "extra read path")?;
492 }
493 for p in &self.extra_write_paths {
494 validate_sandbox_str(p, "extra write path")?;
495 }
496 for p in &self.hidden_paths {
497 validate_sandbox_str(p, "hidden path")?;
498 }
499 Ok(())
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::path::PathBuf;
507
508 fn validate_sandbox_path(path: &Path) -> io::Result<()> {
510 let s = path.to_str().ok_or_else(|| {
511 io::Error::new(
512 io::ErrorKind::InvalidInput,
513 format!("sandbox path is not valid UTF-8: {}", path.display()),
514 )
515 })?;
516 if s.contains(['"', '\\', '\0']) {
517 return Err(io::Error::new(
518 io::ErrorKind::InvalidInput,
519 format!(
520 "sandbox path contains forbidden characters (double-quote, backslash, or null): {}",
521 path.display()
522 ),
523 ));
524 }
525 Ok(())
526 }
527
528 #[test]
531 fn validate_sandbox_path_accepts_normal_path() {
532 let path = PathBuf::from("/Users/agent/workspace/project");
533 assert!(validate_sandbox_path(&path).is_ok());
534 }
535
536 #[test]
537 fn validate_sandbox_path_accepts_path_with_spaces() {
538 let path = PathBuf::from("/Users/agent/my project/src");
539 assert!(validate_sandbox_path(&path).is_ok());
540 }
541
542 #[test]
543 fn validate_sandbox_path_rejects_double_quote() {
544 let path = PathBuf::from("/Users/agent/work\"inject");
545 let err = validate_sandbox_path(&path).unwrap_err();
546 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
547 assert!(
548 err.to_string().contains("forbidden characters"),
549 "unexpected error message: {err}"
550 );
551 }
552
553 #[test]
554 fn validate_sandbox_path_rejects_sbpl_injection_payload() {
555 let path = PathBuf::from(r#"/tmp/evil") (allow file-write* (subpath "/"))"#);
557 let err = validate_sandbox_path(&path).unwrap_err();
558 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
559 assert!(
560 err.to_string().contains("forbidden characters"),
561 "unexpected error message: {err}"
562 );
563 }
564
565 #[test]
566 fn validate_sandbox_path_rejects_backslash() {
567 let path = PathBuf::from("/tmp/work\\nspace");
568 let err = validate_sandbox_path(&path).unwrap_err();
569 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
570 assert!(
571 err.to_string().contains("forbidden characters"),
572 "unexpected error message: {err}"
573 );
574 }
575
576 #[test]
577 fn validate_sandbox_path_rejects_null_byte() {
578 let path = PathBuf::from("/tmp/work\0space");
579 let err = validate_sandbox_path(&path).unwrap_err();
580 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
581 assert!(
582 err.to_string().contains("forbidden characters"),
583 "unexpected error message: {err}"
584 );
585 }
586
587 #[test]
590 fn test_wrap_rejects_non_utf8_path() {
591 use std::ffi::OsStr;
592 use std::os::unix::ffi::OsStrExt;
593
594 let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
595 let bad_path = Path::new(OsStr::from_bytes(bad_bytes));
596 let cmd = Command::new("echo");
597 let result = SandboxCommand::wrap(cmd, bad_path);
598 assert!(result.is_err());
599 let err_msg = result.unwrap_err().to_string();
600 assert!(
601 err_msg.contains("not valid UTF-8"),
602 "error should mention UTF-8: {err_msg}"
603 );
604 }
605
606 #[test]
607 fn test_wrap_rejects_double_quote_path() {
608 let bad_path = Path::new("/tmp/evil\"injection/workspace");
609 let cmd = Command::new("echo");
610 let result = SandboxCommand::wrap(cmd, bad_path);
611 assert!(result.is_err());
612 let err_msg = result.unwrap_err().to_string();
613 assert!(
614 err_msg.contains("forbidden characters"),
615 "error should mention forbidden chars: {err_msg}"
616 );
617 }
618
619 #[test]
620 fn test_wrap_rejects_null_byte_path() {
621 let bad_path = Path::new("/tmp/evil\0null/workspace");
622 let cmd = Command::new("echo");
623 let result = SandboxCommand::wrap(cmd, bad_path);
624 assert!(result.is_err());
625 let err_msg = result.unwrap_err().to_string();
626 assert!(
627 err_msg.contains("forbidden characters"),
628 "error should mention forbidden chars: {err_msg}"
629 );
630 }
631
632 #[test]
633 fn test_wrap_rejects_backslash_path() {
634 let bad_path = Path::new("/tmp/work\\nspace");
635 let cmd = Command::new("echo");
636 let result = SandboxCommand::wrap(cmd, bad_path);
637 assert!(result.is_err());
638 let err_msg = result.unwrap_err().to_string();
639 assert!(
640 err_msg.contains("forbidden characters"),
641 "error should mention forbidden chars: {err_msg}"
642 );
643 }
644
645 #[test]
646 fn test_wrap_rejects_relative_path() {
647 let bad_path = Path::new("relative/workspace");
648 let cmd = Command::new("echo");
649 let result = SandboxCommand::wrap(cmd, bad_path);
650 assert!(result.is_err());
651 let err_msg = result.unwrap_err().to_string();
652 assert!(
653 err_msg.contains("absolute path"),
654 "error should mention absolute path: {err_msg}"
655 );
656 }
657
658 #[cfg(target_os = "macos")]
659 #[test]
660 fn wrap_uses_inline_profile() {
661 let cmd = Command::new("echo");
662 let path = PathBuf::from("/tmp/safe-workspace");
663 let wrapped = SandboxCommand::wrap(cmd, &path).unwrap();
664
665 if super::seatbelt::darwin_major_version() >= 24 {
666 assert_eq!(
667 wrapped.get_program(),
668 "echo",
669 "on macOS 15+, command should pass through unwrapped"
670 );
671 } else {
672 let args: Vec<_> = wrapped.get_args().collect();
673 assert_eq!(args[0], "-p", "expected -p for inline profile delivery");
674 let profile = args[1].to_string_lossy();
675 assert!(
676 profile.contains("/tmp/safe-workspace"),
677 "profile should contain the worktree path"
678 );
679 }
680 }
681
682 #[test]
685 fn test_sandbox_config_builder() {
686 let config = ProcessSandboxConfig::new("/project")
687 .with_network(false)
688 .with_extra_read("/data")
689 .with_extra_write("/output")
690 .with_hidden("/home/user/.astrid");
691
692 assert_eq!(config.writable_root, PathBuf::from("/project"));
693 assert!(!config.allow_network);
694 assert_eq!(config.extra_read_paths, vec![PathBuf::from("/data")]);
695 assert_eq!(config.extra_write_paths, vec![PathBuf::from("/output")]);
696 assert_eq!(
697 config.hidden_paths,
698 vec![PathBuf::from("/home/user/.astrid")]
699 );
700 }
701
702 #[test]
703 fn test_sandbox_config_defaults() {
704 let config = ProcessSandboxConfig::new("/project");
705 assert!(config.allow_network);
706 assert!(config.extra_read_paths.is_empty());
707 assert!(config.extra_write_paths.is_empty());
708 assert!(config.hidden_paths.is_empty());
709 }
710
711 #[test]
714 fn policy_parse_accepts_known_values() {
715 assert_eq!(
716 SandboxPolicy::parse("required"),
717 Some(SandboxPolicy::Required)
718 );
719 assert_eq!(
720 SandboxPolicy::parse("Required"),
721 Some(SandboxPolicy::Required)
722 );
723 assert_eq!(SandboxPolicy::parse("OFF"), Some(SandboxPolicy::Off));
724 assert_eq!(SandboxPolicy::parse(" off "), Some(SandboxPolicy::Off));
725 }
726
727 #[test]
728 fn policy_parse_rejects_unknown_values() {
729 assert_eq!(SandboxPolicy::parse(""), None);
730 assert_eq!(SandboxPolicy::parse("preferred"), None);
733 assert_eq!(SandboxPolicy::parse("relaxed"), None);
734 assert_eq!(SandboxPolicy::parse("required-ish"), None);
735 }
736
737 #[test]
738 fn policy_default_is_required() {
739 assert_eq!(SandboxPolicy::default(), SandboxPolicy::Required);
740 }
741
742 #[test]
743 #[allow(unsafe_code)] fn config_default_policy_is_required_when_env_unset() {
745 unsafe {
756 std::env::remove_var("ASTRID_SANDBOX_POLICY");
757 }
758 let config = ProcessSandboxConfig::new("/project");
759 assert_eq!(
760 config.policy,
761 SandboxPolicy::Required,
762 "fresh config with unset env must default to Required — \
763 silent unsandboxed launches are the bug from #655"
764 );
765 }
766
767 #[test]
768 fn with_policy_overrides_default() {
769 let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
770 assert_eq!(config.policy, SandboxPolicy::Off);
771 }
772
773 #[test]
774 fn sandbox_prefix_with_off_policy_returns_none_silently() {
775 let config = ProcessSandboxConfig::new("/project").with_policy(SandboxPolicy::Off);
778 let result = config.sandbox_prefix();
779 assert!(matches!(result, Ok(None)));
780 }
781
782 #[test]
789 fn test_sandbox_prefix_rejects_relative_writable_root() {
790 let config = ProcessSandboxConfig::new("relative/project");
791 assert!(config.sandbox_prefix().is_err());
792 }
793
794 #[test]
795 fn test_sandbox_prefix_rejects_non_utf8_writable_root() {
796 use std::ffi::OsStr;
797 use std::os::unix::ffi::OsStrExt;
798
799 let bad_bytes: &[u8] = b"/tmp/\xff\xfe/workspace";
800 let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
801 let config = ProcessSandboxConfig::new(bad_path);
802 let result = config.sandbox_prefix();
803 assert!(result.is_err());
804 assert!(result.unwrap_err().to_string().contains("not valid UTF-8"));
805 }
806
807 #[test]
808 fn test_sandbox_prefix_rejects_non_utf8_extra_paths() {
809 use std::ffi::OsStr;
810 use std::os::unix::ffi::OsStrExt;
811
812 let bad_bytes: &[u8] = b"/data/\xff\xfe";
813 let bad_path = PathBuf::from(OsStr::from_bytes(bad_bytes));
814
815 let config = ProcessSandboxConfig::new("/project").with_extra_read(bad_path.clone());
816 assert!(config.sandbox_prefix().is_err());
817
818 let config = ProcessSandboxConfig::new("/project").with_extra_write(bad_path.clone());
819 assert!(config.sandbox_prefix().is_err());
820
821 let config = ProcessSandboxConfig::new("/project").with_hidden(bad_path);
822 assert!(config.sandbox_prefix().is_err());
823 }
824
825 #[test]
826 fn test_sandbox_prefix_rejects_double_quote_in_paths() {
827 let config = ProcessSandboxConfig::new("/project/evil\"dir");
828 assert!(config.sandbox_prefix().is_err());
829
830 let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\"path");
831 assert!(config.sandbox_prefix().is_err());
832
833 let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\"path");
834 assert!(config.sandbox_prefix().is_err());
835
836 let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\"path");
837 assert!(config.sandbox_prefix().is_err());
838 }
839
840 #[test]
841 fn test_sandbox_prefix_rejects_backslash_in_paths() {
842 let config = ProcessSandboxConfig::new("/project/evil\\dir");
843 assert!(config.sandbox_prefix().is_err());
844
845 let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\\path");
846 assert!(config.sandbox_prefix().is_err());
847
848 let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\\path");
849 assert!(config.sandbox_prefix().is_err());
850
851 let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\\path");
852 assert!(config.sandbox_prefix().is_err());
853 }
854
855 #[test]
856 fn test_sandbox_prefix_rejects_null_byte_in_paths() {
857 let config = ProcessSandboxConfig::new("/project/evil\0dir");
858 assert!(config.sandbox_prefix().is_err());
859
860 let config = ProcessSandboxConfig::new("/project").with_extra_read("/data/evil\0path");
861 assert!(config.sandbox_prefix().is_err());
862
863 let config = ProcessSandboxConfig::new("/project").with_extra_write("/output/evil\0path");
864 assert!(config.sandbox_prefix().is_err());
865
866 let config = ProcessSandboxConfig::new("/project").with_hidden("/hidden/evil\0path");
867 assert!(config.sandbox_prefix().is_err());
868 }
869}