1use async_trait::async_trait;
36use harness_core::{RepoView, World};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use thiserror::Error;
40
41#[derive(Debug, Error)]
42pub enum SandboxError {
43 #[error("git not available: {0}")]
44 GitMissing(String),
45 #[error("git error: {0}")]
46 Git(String),
47 #[error("io error: {0}")]
48 Io(String),
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum FsPolicy {
54 Confined,
56 HostReadConfinedWrite,
58 Unrestricted,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum NetPolicy {
65 None,
67 Allowed,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Isolation {
76 None,
78 Changes,
81 Process,
84}
85
86#[async_trait]
87pub trait Sandbox: Send + Sync {
88 async fn spawn(&self) -> Result<SandboxHandle, SandboxError>;
89 fn fs_policy(&self) -> FsPolicy;
90 fn net_policy(&self) -> NetPolicy;
91 fn isolation(&self) -> Isolation;
93}
94
95pub struct SandboxHandle {
98 pub world: World,
99 pub root: PathBuf,
100 cleanup: Option<Box<dyn FnOnce() + Send>>,
101 keep: bool,
102 label: String,
103}
104
105impl SandboxHandle {
106 pub fn label(&self) -> &str {
107 &self.label
108 }
109 pub fn root(&self) -> &Path {
110 &self.root
111 }
112
113 pub fn keep(&mut self) {
116 self.keep = true;
117 }
118
119 pub fn into_world(self) -> World {
124 let md = std::mem::ManuallyDrop::new(self);
125 unsafe { std::ptr::read(&md.world) }
129 }
130}
131
132impl Drop for SandboxHandle {
133 fn drop(&mut self) {
134 if self.keep {
135 return;
136 }
137 if let Some(f) = self.cleanup.take() {
138 f();
139 }
140 }
141}
142
143pub struct WorktreeSandbox {
151 pub source: PathBuf,
153 pub branch: String,
155 pub start_ref: Option<String>,
157 pub target: Option<PathBuf>,
159}
160
161impl WorktreeSandbox {
162 pub fn new(source: impl Into<PathBuf>, branch: impl Into<String>) -> Self {
163 Self {
164 source: source.into(),
165 branch: branch.into(),
166 start_ref: None,
167 target: None,
168 }
169 }
170
171 pub fn with_target(mut self, p: impl Into<PathBuf>) -> Self {
172 self.target = Some(p.into());
173 self
174 }
175
176 pub fn with_start_ref(mut self, r: impl Into<String>) -> Self {
177 self.start_ref = Some(r.into());
178 self
179 }
180}
181
182#[async_trait]
183impl Sandbox for WorktreeSandbox {
184 fn fs_policy(&self) -> FsPolicy {
185 FsPolicy::Confined
187 }
188 fn net_policy(&self) -> NetPolicy {
189 NetPolicy::Allowed
190 }
191 fn isolation(&self) -> Isolation {
192 Isolation::Changes
193 }
194
195 async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
196 let target = match &self.target {
198 Some(p) => p.clone(),
199 None => {
200 let stamp = std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .map(|d| d.as_nanos())
203 .unwrap_or(0);
204 std::env::temp_dir().join(format!("harness-worktree-{stamp}"))
205 }
206 };
207
208 let probe = tokio::process::Command::new("git")
210 .arg("--version")
211 .output()
212 .await
213 .map_err(|e| SandboxError::GitMissing(format!("`git --version` failed: {e}")))?;
214 if !probe.status.success() {
215 return Err(SandboxError::GitMissing(
216 String::from_utf8_lossy(&probe.stderr).into_owned(),
217 ));
218 }
219
220 let start = self.start_ref.clone().unwrap_or_else(|| "HEAD".to_string());
222 let target_str = target.to_string_lossy().to_string();
223 let args: Vec<&str> = vec!["worktree", "add", "-b", &self.branch, &target_str, &start];
224 tracing::info!(branch=%self.branch, target=%target.display(), "spawning worktree");
225 let out = tokio::process::Command::new("git")
226 .current_dir(&self.source)
227 .args(&args)
228 .output()
229 .await
230 .map_err(|e| SandboxError::Io(e.to_string()))?;
231 if !out.status.success() {
232 return Err(SandboxError::Git(format!(
233 "git worktree add failed: {}",
234 String::from_utf8_lossy(&out.stderr)
235 )));
236 }
237
238 let world = World {
240 repo: RepoView {
241 root: target.clone(),
242 },
243 runner: Arc::new(harness_context::TokioRunner),
244 clock: Arc::new(harness_context::SystemClock),
245 kv: Arc::new(harness_context::InMemoryKv::new()),
246 profile: harness_core::UserProfile::default(),
247 session: None,
248 };
249
250 let cleanup_source = self.source.clone();
251 let cleanup_target = target.clone();
252 let cleanup_branch = self.branch.clone();
253 let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
254 let target_str = cleanup_target.to_string_lossy().to_string();
255 let args: Vec<&str> = vec!["worktree", "remove", "--force", &target_str];
256 let res = std::process::Command::new("git")
257 .current_dir(&cleanup_source)
258 .args(&args)
259 .output();
260 if let Ok(o) = res
261 && !o.status.success()
262 {
263 tracing::warn!(
264 "git worktree remove failed for {}: {}",
265 cleanup_target.display(),
266 String::from_utf8_lossy(&o.stderr)
267 );
268 }
269 let _ = std::process::Command::new("git")
271 .current_dir(&cleanup_source)
272 .args(["branch", "-D", &cleanup_branch])
273 .output();
274 });
275
276 Ok(SandboxHandle {
277 world,
278 root: target,
279 cleanup: Some(cleanup),
280 keep: false,
281 label: format!("worktree:{}", self.branch),
282 })
283 }
284}
285
286pub struct ContainerSandbox {
297 pub image: String,
299 pub source: PathBuf,
301 pub name: Option<String>,
303 pub no_net: bool,
305}
306
307impl ContainerSandbox {
308 pub fn new(image: impl Into<String>, source: impl Into<PathBuf>) -> Self {
309 Self {
310 image: image.into(),
311 source: source.into(),
312 name: None,
313 no_net: true,
314 }
315 }
316
317 pub fn with_network(mut self, allow: bool) -> Self {
318 self.no_net = !allow;
319 self
320 }
321
322 pub fn with_name(mut self, name: impl Into<String>) -> Self {
323 self.name = Some(name.into());
324 self
325 }
326}
327
328#[async_trait]
329impl Sandbox for ContainerSandbox {
330 fn fs_policy(&self) -> FsPolicy {
331 FsPolicy::Confined
332 }
333 fn net_policy(&self) -> NetPolicy {
334 if self.no_net {
335 NetPolicy::None
336 } else {
337 NetPolicy::Allowed
338 }
339 }
340 fn isolation(&self) -> Isolation {
341 Isolation::Process
342 }
343
344 async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
345 let probe = tokio::process::Command::new("docker")
347 .arg("--version")
348 .output()
349 .await
350 .map_err(|e| SandboxError::GitMissing(format!("docker missing: {e}")))?;
351 if !probe.status.success() {
352 return Err(SandboxError::GitMissing(
353 String::from_utf8_lossy(&probe.stderr).to_string(),
354 ));
355 }
356
357 let name = self.name.clone().unwrap_or_else(|| {
358 format!(
359 "harness-sb-{}-{}",
360 std::process::id(),
361 std::time::SystemTime::now()
362 .duration_since(std::time::UNIX_EPOCH)
363 .map(|d| d.as_nanos())
364 .unwrap_or(0)
365 )
366 });
367 let mount = format!("{}:/workspace", self.source.display());
368 let mut args = vec![
369 "run",
370 "-d",
371 "--rm",
372 "--name",
373 &name,
374 "-v",
375 &mount,
376 "-w",
377 "/workspace",
378 ];
379 if self.no_net {
380 args.push("--network");
381 args.push("none");
382 }
383 args.push(&self.image);
384 args.push("sleep");
385 args.push("infinity");
386
387 tracing::info!(image=%self.image, name=%name, "spawning container sandbox");
388 let out = tokio::process::Command::new("docker")
389 .args(&args)
390 .output()
391 .await
392 .map_err(|e| SandboxError::Io(e.to_string()))?;
393 if !out.status.success() {
394 return Err(SandboxError::Git(format!(
395 "docker run failed: {}",
396 String::from_utf8_lossy(&out.stderr)
397 )));
398 }
399
400 let runner: Arc<dyn harness_core::ProcessRunner> = Arc::new(DockerExecRunner {
402 container: name.clone(),
403 });
404 let world = World {
405 repo: RepoView {
406 root: self.source.clone(),
407 },
408 runner,
409 clock: Arc::new(harness_context::SystemClock),
410 kv: Arc::new(harness_context::InMemoryKv::new()),
411 profile: harness_core::UserProfile::default(),
412 session: None,
413 };
414
415 let kill_name = name.clone();
416 let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
417 let _ = std::process::Command::new("docker")
418 .args(["kill", &kill_name])
419 .output();
420 });
421
422 Ok(SandboxHandle {
423 world,
424 root: self.source.clone(),
425 cleanup: Some(cleanup),
426 keep: false,
427 label: format!("container:{name}"),
428 })
429 }
430}
431
432struct DockerExecRunner {
433 container: String,
434}
435
436#[async_trait]
437impl harness_core::ProcessRunner for DockerExecRunner {
438 async fn exec(
439 &self,
440 program: &str,
441 args: &[&str],
442 cwd: Option<&std::path::Path>,
443 ) -> std::io::Result<harness_core::ProcessOutput> {
444 let mut docker_args: Vec<String> = vec!["exec".into()];
445 if let Some(c) = cwd {
446 docker_args.push("-w".into());
447 let inside = if c.is_absolute() {
449 c.display().to_string()
450 } else {
451 format!("/workspace/{}", c.display())
452 };
453 docker_args.push(inside);
454 }
455 docker_args.push(self.container.clone());
456 docker_args.push(program.into());
457 for a in args {
458 docker_args.push((*a).into());
459 }
460 let out = tokio::process::Command::new("docker")
461 .args(&docker_args)
462 .output()
463 .await?;
464 Ok(harness_core::ProcessOutput {
465 status: out.status.code().unwrap_or(-1),
466 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
467 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
468 })
469 }
470}
471
472pub struct SeatbeltSandbox {
486 pub root: PathBuf,
487 pub allow_net: bool,
488 pub confine_writes: bool,
489 pub confine_reads: bool,
490}
491
492impl SeatbeltSandbox {
493 pub fn new(root: impl Into<PathBuf>) -> Self {
494 Self {
495 root: root.into(),
496 allow_net: false,
497 confine_writes: false,
498 confine_reads: false,
499 }
500 }
501 pub fn with_network(mut self, allow: bool) -> Self {
502 self.allow_net = allow;
503 self
504 }
505 pub fn with_confine_writes(mut self, confine: bool) -> Self {
506 self.confine_writes = confine;
507 self
508 }
509 pub fn with_confine_reads(mut self, confine: bool) -> Self {
522 self.confine_reads = confine;
523 if confine {
524 self.confine_writes = true;
525 }
526 self
527 }
528}
529
530const MACOS_READ_ALLOW: &[&str] = &[
546 "/usr",
547 "/bin",
548 "/sbin",
549 "/System",
550 "/Library",
551 "/opt",
552 "/nix",
553 "/etc",
554 "/private/etc",
555 "/var/select",
556 "/dev",
557 "/private/var/db/dyld",
558];
559
560pub fn seatbelt_profile(
562 root: &Path,
563 allow_net: bool,
564 confine_writes: bool,
565 confine_reads: bool,
566) -> String {
567 let mut p = String::from("(version 1)\n(allow default)\n");
568 if !allow_net {
569 p.push_str("(deny network*)\n");
570 }
571 if confine_writes {
572 p.push_str("(deny file-write*)\n");
573 p.push_str(&format!(
574 "(allow file-write* (subpath \"{}\"))\n",
575 root.display()
576 ));
577 p.push_str(
578 "(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))\n",
579 );
580 p.push_str("(allow file-write* (literal \"/dev/null\") (literal \"/dev/stdout\") (literal \"/dev/stderr\") (literal \"/dev/dtracehelper\"))\n");
581 }
582 if confine_reads {
583 p.push_str("(deny file-read*)\n");
584 p.push_str("(allow file-read-metadata)\n");
589 p.push_str("(allow file-read* (literal \"/\"))\n");
595 p.push_str(&format!(
596 "(allow file-read* (subpath \"{}\"))\n",
597 root.display()
598 ));
599 for path in MACOS_READ_ALLOW {
600 p.push_str(&format!("(allow file-read* (subpath \"{path}\"))\n"));
601 }
602 }
603 p
604}
605
606struct SeatbeltRunner {
607 profile: String,
608 private_tmp: Option<PathBuf>,
616}
617
618#[async_trait]
619impl harness_core::ProcessRunner for SeatbeltRunner {
620 async fn exec(
621 &self,
622 program: &str,
623 args: &[&str],
624 cwd: Option<&std::path::Path>,
625 ) -> std::io::Result<harness_core::ProcessOutput> {
626 let mut sb: Vec<String> = vec!["-p".into(), self.profile.clone(), program.into()];
628 sb.extend(args.iter().map(|a| (*a).to_string()));
629 let mut cmd = tokio::process::Command::new("sandbox-exec");
630 cmd.args(&sb);
631 if let Some(t) = &self.private_tmp {
632 std::fs::create_dir_all(t)?;
633 cmd.env("TMPDIR", t).env("TMP", t).env("TEMP", t);
636 }
637 if let Some(c) = cwd {
638 cmd.current_dir(c);
639 }
640 let out = cmd.output().await?;
641 Ok(harness_core::ProcessOutput {
642 status: out.status.code().unwrap_or(-1),
643 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
644 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
645 })
646 }
647}
648
649#[async_trait]
650impl Sandbox for SeatbeltSandbox {
651 fn fs_policy(&self) -> FsPolicy {
652 match (self.confine_reads, self.confine_writes) {
653 (true, _) => FsPolicy::Confined,
654 (false, true) => FsPolicy::HostReadConfinedWrite,
655 (false, false) => FsPolicy::Unrestricted,
656 }
657 }
658 fn net_policy(&self) -> NetPolicy {
659 if self.allow_net {
660 NetPolicy::Allowed
661 } else {
662 NetPolicy::None
663 }
664 }
665 fn isolation(&self) -> Isolation {
666 Isolation::Process
667 }
668
669 async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
670 let probe = tokio::process::Command::new("sandbox-exec")
671 .arg("-p")
672 .arg("(version 1)(allow default)")
673 .arg("/usr/bin/true")
674 .output()
675 .await
676 .map_err(|e| SandboxError::GitMissing(format!("sandbox-exec unavailable: {e}")))?;
677 if !probe.status.success() {
678 return Err(SandboxError::GitMissing(
679 "sandbox-exec probe failed (macOS only)".into(),
680 ));
681 }
682 let world = World {
683 repo: RepoView {
684 root: self.root.clone(),
685 },
686 runner: Arc::new(SeatbeltRunner {
687 profile: seatbelt_profile(
688 &self.root,
689 self.allow_net,
690 self.confine_writes,
691 self.confine_reads,
692 ),
693 private_tmp: self.confine_reads.then(|| self.root.join(".tmp")),
694 }),
695 clock: Arc::new(harness_context::SystemClock),
696 kv: Arc::new(harness_context::InMemoryKv::new()),
697 profile: harness_core::UserProfile::default(),
698 session: None,
699 };
700 Ok(SandboxHandle {
701 world,
702 root: self.root.clone(),
703 cleanup: None,
704 keep: false,
705 label: "seatbelt".into(),
706 })
707 }
708}
709
710pub struct BubblewrapSandbox {
721 pub root: PathBuf,
722 pub allow_net: bool,
723 pub confine_writes: bool,
724 pub confine_reads: bool,
725}
726
727impl BubblewrapSandbox {
728 pub fn new(root: impl Into<PathBuf>) -> Self {
729 Self {
730 root: root.into(),
731 allow_net: false,
732 confine_writes: true,
733 confine_reads: false,
734 }
735 }
736 pub fn with_network(mut self, allow: bool) -> Self {
737 self.allow_net = allow;
738 self
739 }
740 pub fn with_confine_writes(mut self, confine: bool) -> Self {
741 self.confine_writes = confine;
742 self
743 }
744 pub fn with_confine_reads(mut self, confine: bool) -> Self {
750 self.confine_reads = confine;
751 if confine {
752 self.confine_writes = true;
753 }
754 self
755 }
756}
757
758const LINUX_READ_BIND: &[&str] = &[
766 "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/nix",
767];
768
769pub fn bwrap_args(
773 root: &Path,
774 allow_net: bool,
775 confine_writes: bool,
776 confine_reads: bool,
777) -> Vec<String> {
778 let r = root.display().to_string();
779 let mut a: Vec<String> = Vec::new();
780 if confine_reads {
781 for path in LINUX_READ_BIND {
785 a.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]);
786 }
787 a.extend(["--bind".into(), r.clone(), r]);
788 a.extend(["--tmpfs".into(), "/tmp".into()]);
791 } else if confine_writes {
792 a.extend(["--ro-bind".into(), "/".into(), "/".into()]);
793 a.extend(["--bind".into(), r.clone(), r]);
794 } else {
795 a.extend(["--bind".into(), "/".into(), "/".into()]);
796 }
797 a.extend(["--proc".into(), "/proc".into()]);
798 a.extend(["--dev".into(), "/dev".into()]);
799 if !allow_net {
800 a.push("--unshare-net".into());
801 }
802 a
803}
804
805struct BwrapRunner {
806 args: Vec<String>,
807}
808
809#[async_trait]
810impl harness_core::ProcessRunner for BwrapRunner {
811 async fn exec(
812 &self,
813 program: &str,
814 args: &[&str],
815 cwd: Option<&std::path::Path>,
816 ) -> std::io::Result<harness_core::ProcessOutput> {
817 let mut full = self.args.clone();
818 full.push(program.into());
819 full.extend(args.iter().map(|a| (*a).to_string()));
820 let mut cmd = tokio::process::Command::new("bwrap");
821 cmd.args(&full);
822 if let Some(c) = cwd {
823 cmd.current_dir(c);
824 }
825 let out = cmd.output().await?;
826 Ok(harness_core::ProcessOutput {
827 status: out.status.code().unwrap_or(-1),
828 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
829 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
830 })
831 }
832}
833
834#[async_trait]
835impl Sandbox for BubblewrapSandbox {
836 fn fs_policy(&self) -> FsPolicy {
837 match (self.confine_reads, self.confine_writes) {
838 (true, _) => FsPolicy::Confined,
839 (false, true) => FsPolicy::HostReadConfinedWrite,
840 (false, false) => FsPolicy::Unrestricted,
841 }
842 }
843 fn net_policy(&self) -> NetPolicy {
844 if self.allow_net {
845 NetPolicy::Allowed
846 } else {
847 NetPolicy::None
848 }
849 }
850 fn isolation(&self) -> Isolation {
851 Isolation::Process
852 }
853
854 async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
855 let probe = tokio::process::Command::new("bwrap")
856 .arg("--version")
857 .output()
858 .await
859 .map_err(|e| SandboxError::GitMissing(format!("bwrap unavailable: {e}")))?;
860 if !probe.status.success() {
861 return Err(SandboxError::GitMissing(
862 "bwrap probe failed (Linux only)".into(),
863 ));
864 }
865 let runner = Arc::new(BwrapRunner {
866 args: bwrap_args(
867 &self.root,
868 self.allow_net,
869 self.confine_writes,
870 self.confine_reads,
871 ),
872 });
873 let world = World {
874 repo: RepoView {
875 root: self.root.clone(),
876 },
877 runner,
878 clock: Arc::new(harness_context::SystemClock),
879 kv: Arc::new(harness_context::InMemoryKv::new()),
880 profile: harness_core::UserProfile::default(),
881 session: None,
882 };
883 Ok(SandboxHandle {
884 world,
885 root: self.root.clone(),
886 cleanup: None,
887 keep: false,
888 label: "bubblewrap".into(),
889 })
890 }
891}
892
893pub struct NullSandbox {
899 pub root: PathBuf,
900}
901
902impl NullSandbox {
903 pub fn new(root: impl Into<PathBuf>) -> Self {
904 Self { root: root.into() }
905 }
906}
907
908#[async_trait]
909impl Sandbox for NullSandbox {
910 fn fs_policy(&self) -> FsPolicy {
911 FsPolicy::Unrestricted
912 }
913 fn net_policy(&self) -> NetPolicy {
914 NetPolicy::Allowed
915 }
916 fn isolation(&self) -> Isolation {
917 Isolation::None
918 }
919
920 async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
921 let world = World {
922 repo: RepoView {
923 root: self.root.clone(),
924 },
925 runner: Arc::new(harness_context::TokioRunner),
926 clock: Arc::new(harness_context::SystemClock),
927 kv: Arc::new(harness_context::InMemoryKv::new()),
928 profile: harness_core::UserProfile::default(),
929 session: None,
930 };
931 Ok(SandboxHandle {
932 world,
933 root: self.root.clone(),
934 cleanup: None,
935 keep: false,
936 label: "null".into(),
937 })
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 #[tokio::test]
946 async fn container_sandbox_fails_cleanly_without_docker() {
947 let s = ContainerSandbox::new("harness-nonexistent-image-xyzzy:latest", ".");
948 let outcome = tokio::time::timeout(std::time::Duration::from_secs(20), s.spawn()).await;
954 match outcome {
955 Ok(spawned) => assert!(spawned.is_err(), "expected error spawning bogus container"),
956 Err(_elapsed) => { }
957 }
958 }
959
960 #[tokio::test]
961 async fn null_sandbox_returns_world() {
962 let s = NullSandbox::new(".");
963 let h = s.spawn().await.unwrap();
964 assert_eq!(h.label(), "null");
965 }
966
967 #[test]
968 fn seatbelt_profile_denies_network_by_default() {
969 let p = seatbelt_profile(std::path::Path::new("/tmp/x"), false, false, false);
970 assert!(p.contains("(deny network*)"));
971 let allowed = seatbelt_profile(std::path::Path::new("/tmp/x"), true, false, false);
972 assert!(!allowed.contains("(deny network*)"));
973 }
974
975 #[test]
976 fn seatbelt_read_confinement_is_off_unless_asked() {
977 let p = seatbelt_profile(std::path::Path::new("/work"), false, true, false);
978 assert!(
979 !p.contains("(deny file-read*)"),
980 "write confinement must not silently imply read confinement"
981 );
982 }
983
984 #[test]
985 fn seatbelt_read_confinement_allows_root_and_system_paths() {
986 let p = seatbelt_profile(std::path::Path::new("/work/tenant-a"), false, true, true);
987 assert!(p.contains("(deny file-read*)"));
988 assert!(p.contains("(allow file-read* (subpath \"/work/tenant-a\"))"));
989 for required in ["/usr", "/System", "/private/var/db/dyld"] {
991 assert!(
992 p.contains(&format!("(allow file-read* (subpath \"{required}\"))")),
993 "missing read allow for {required}"
994 );
995 }
996 assert!(p.contains("(allow file-read-metadata)"));
998 assert!(p.contains("(allow file-read* (literal \"/\"))"));
1000 assert!(!p.contains("/work/tenant-b"));
1002 assert!(p.contains(
1005 "(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))"
1006 ));
1007 assert!(!p.contains("(allow file-read* (subpath \"/private/tmp\"))"));
1008 assert!(!p.contains("(allow file-read* (subpath \"/private/var/folders\"))"));
1009 }
1010
1011 #[test]
1012 fn seatbelt_confine_reads_implies_confine_writes() {
1013 let s = SeatbeltSandbox::new("/work").with_confine_reads(true);
1014 assert!(s.confine_writes);
1015 assert_eq!(s.fs_policy(), FsPolicy::Confined);
1016 assert_eq!(
1017 SeatbeltSandbox::new("/work")
1018 .with_confine_writes(true)
1019 .fs_policy(),
1020 FsPolicy::HostReadConfinedWrite
1021 );
1022 }
1023
1024 #[test]
1025 fn bwrap_args_deny_net_and_confine_writes_by_default() {
1026 let a = bwrap_args(std::path::Path::new("/work"), false, true, false);
1027 assert!(a.iter().any(|x| x == "--unshare-net"));
1028 assert!(a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]));
1029 assert!(a.windows(3).any(|w| w == ["--bind", "/work", "/work"]));
1030 let net = bwrap_args(std::path::Path::new("/work"), true, true, false);
1031 assert!(!net.iter().any(|x| x == "--unshare-net"));
1032 }
1033
1034 #[test]
1035 fn bwrap_read_confinement_binds_system_paths_only() {
1036 let a = bwrap_args(std::path::Path::new("/work/tenant-a"), false, true, true);
1037 assert!(
1039 !a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]),
1040 "read-confined sandbox must not bind the whole filesystem"
1041 );
1042 assert!(a.windows(3).any(|w| w == ["--ro-bind-try", "/usr", "/usr"]));
1043 assert!(
1044 a.windows(3)
1045 .any(|w| w == ["--bind", "/work/tenant-a", "/work/tenant-a"])
1046 );
1047 assert!(!a.iter().any(|x| x == "/home"));
1049 assert!(a.windows(2).any(|w| w == ["--tmpfs", "/tmp"]));
1051 }
1052
1053 #[test]
1054 fn bwrap_confine_reads_implies_confine_writes() {
1055 let s = BubblewrapSandbox::new("/work").with_confine_reads(true);
1056 assert!(s.confine_writes);
1057 assert_eq!(s.fs_policy(), FsPolicy::Confined);
1058 }
1059
1060 #[tokio::test]
1061 async fn bubblewrap_fails_cleanly_off_linux() {
1062 let absent = std::process::Command::new("bwrap")
1064 .arg("--version")
1065 .output()
1066 .map(|o| !o.status.success())
1067 .unwrap_or(true);
1068 if absent {
1069 assert!(BubblewrapSandbox::new(".").spawn().await.is_err());
1070 }
1071 }
1072
1073 #[cfg(target_os = "macos")]
1083 #[tokio::test]
1084 async fn seatbelt_confined_reads_block_a_sibling_tenant() {
1085 let base =
1086 std::env::temp_dir().join(format!("harness-sandbox-reads-{}", std::process::id()));
1087 let mine = base.join("tenant-a");
1088 let theirs = base.join("tenant-b");
1089 std::fs::create_dir_all(&mine).unwrap();
1090 std::fs::create_dir_all(&theirs).unwrap();
1091 let my_file = mine.join("ok.txt");
1092 let their_secret = theirs.join("secret.txt");
1093 std::fs::write(&my_file, "mine\n").unwrap();
1094 std::fs::write(&their_secret, "SECRET\n").unwrap();
1095
1096 let real_root = std::fs::canonicalize(&mine).unwrap();
1101 let real_secret = std::fs::canonicalize(&their_secret).unwrap();
1102
1103 let h = SeatbeltSandbox::new(&real_root)
1104 .with_confine_reads(true)
1105 .spawn()
1106 .await
1107 .expect("seatbelt spawns");
1108
1109 let own = h
1111 .world
1112 .runner
1113 .exec(
1114 "/bin/cat",
1115 &[real_root.join("ok.txt").to_str().unwrap()],
1116 None,
1117 )
1118 .await
1119 .unwrap();
1120 assert_eq!(own.status, 0, "own workspace must stay readable: {own:?}");
1121 assert!(own.stdout.contains("mine"));
1122
1123 let stolen = h
1125 .world
1126 .runner
1127 .exec("/bin/cat", &[real_secret.to_str().unwrap()], None)
1128 .await
1129 .unwrap();
1130 assert_ne!(stolen.status, 0, "sibling tenant read must be denied");
1131 assert!(
1132 !stolen.stdout.contains("SECRET"),
1133 "secret leaked out of the sandbox: {stolen:?}"
1134 );
1135
1136 let _ = std::fs::remove_dir_all(&base);
1137 }
1138
1139 #[cfg(target_os = "macos")]
1143 #[tokio::test]
1144 async fn seatbelt_denies_child_network_without_touching_parent() {
1145 let curl = "/usr/bin/curl";
1146 if !std::path::Path::new(curl).exists() {
1147 return;
1148 }
1149 let probe = ["-s", "-m", "6", "-o", "/dev/null", "https://example.com"];
1150 let parent_net = || {
1151 std::process::Command::new(curl)
1152 .args(probe)
1153 .output()
1154 .map(|o| o.status.code().unwrap_or(-1))
1155 .unwrap_or(-1)
1156 };
1157 if parent_net() != 0 {
1158 return; }
1160
1161 let h = SeatbeltSandbox::new(".")
1162 .spawn()
1163 .await
1164 .expect("seatbelt spawns");
1165 let echo = h
1167 .world
1168 .runner
1169 .exec("/bin/echo", &["hi"], None)
1170 .await
1171 .unwrap();
1172 assert_eq!(echo.status, 0);
1173 let blocked = h.world.runner.exec(curl, &probe, None).await.unwrap();
1175 assert_ne!(blocked.status, 0, "child network must be denied");
1176 let unrestricted = (0..3).any(|_| parent_net() == 0);
1185 assert!(unrestricted, "parent must remain unsandboxed");
1186 }
1187
1188 #[tokio::test]
1189 async fn worktree_sandbox_spawns_and_cleans_up() {
1190 let probe = std::process::Command::new("git")
1192 .args(["rev-parse", "--show-toplevel"])
1193 .output();
1194 let Ok(out) = probe else {
1195 return;
1196 };
1197 if !out.status.success() {
1198 return;
1199 }
1200 let src = String::from_utf8_lossy(&out.stdout).trim().to_string();
1201 let branch = format!("harness-test-{}", std::process::id());
1202 let s = WorktreeSandbox::new(&src, &branch);
1203 let handle = s.spawn().await.expect("worktree spawns");
1204 assert!(handle.root().exists(), "worktree directory exists");
1205 assert!(handle.label().starts_with("worktree:"));
1206 let root = handle.root().to_path_buf();
1207 drop(handle);
1208 let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1211 while root.exists() {
1212 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1213 }
1214 })
1215 .await;
1216 }
1217}