1use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22use serde::{Deserialize, Serialize};
23
24use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Network {
30 Off,
32 Loopback,
35 On,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Mode {
42 Auto,
44 Required,
46 Off,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Config {
53 pub mode: Mode,
55 pub network: Network,
57 pub network_allow: Vec<Vec<String>>,
59 pub writable: Vec<String>,
61}
62
63impl Default for Config {
64 fn default() -> Self {
65 Self {
66 mode: Mode::Auto,
67 network: Network::Loopback,
68 network_allow: Vec::new(),
69 writable: Vec::new(),
70 }
71 }
72}
73
74#[derive(Debug, Default, Deserialize)]
75#[serde(deny_unknown_fields)]
76struct Raw {
77 mode: Option<String>,
78 network: Option<String>,
79 #[serde(default)]
80 network_allow: Vec<Vec<String>>,
81 #[serde(default)]
82 writable: Vec<String>,
83}
84
85pub fn parse(text: &str) -> Result<Config, String> {
87 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
88 let Some(v) = table.get("sandbox") else {
89 return Ok(Config::default());
90 };
91 let raw: Raw = v
92 .clone()
93 .try_into()
94 .map_err(|e| format!("[sandbox]: {e}"))?;
95 let mode = match raw.mode.as_deref() {
96 None | Some("auto") => Mode::Auto,
97 Some("required") => Mode::Required,
98 Some("off") => Mode::Off,
99 Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
100 };
101 let network = match raw.network.as_deref() {
102 None | Some("loopback") => Network::Loopback,
103 Some("off") => Network::Off,
104 Some("on") => Network::On,
105 Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
106 };
107 Ok(Config {
108 mode,
109 network,
110 network_allow: raw
111 .network_allow
112 .into_iter()
113 .filter(|p| !p.is_empty())
114 .collect(),
115 writable: raw.writable,
116 })
117}
118
119pub const HIDDEN: &[&str] = &[
121 ".ssh",
122 ".aws",
123 ".azure",
124 ".gnupg",
125 ".netrc",
126 ".docker",
127 ".kube",
128 ".npmrc",
129 ".pypirc",
130 ".git-credentials",
131 ".password-store",
132 ".config/gh",
133 ".config/gcloud",
134 ".config/hub",
135 ".cargo/credentials",
136 ".cargo/credentials.toml",
137 ".local/share/keyrings",
138 "Library/Keychains",
139];
140
141pub const CACHES: &[&str] = &[
144 ".cargo/registry",
145 ".cargo/git",
146 ".cargo/.package-cache",
147 ".cargo/.package-cache-mutate",
148 ".cargo/.global-cache",
149 ".cache",
150 ".npm",
151 ".pnpm-store",
152 ".yarn/berry/cache",
153 "go/pkg/mod",
154 "Library/Caches",
155];
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct Profile {
160 pub writable: Vec<PathBuf>,
162 pub hidden: Vec<PathBuf>,
164 pub read_only: Vec<PathBuf>,
167 pub network: Network,
169 pub tmp: PathBuf,
171}
172
173fn canonical(p: &Path) -> PathBuf {
174 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
175}
176
177impl Profile {
178 #[must_use]
180 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
181 let root = canonical(root);
182 let expand = |p: &str| -> Option<PathBuf> {
183 match (p.strip_prefix("~/"), home) {
184 (Some(rest), Some(h)) => Some(h.join(rest)),
185 (Some(_), None) => None,
186 (None, _) => Some(PathBuf::from(p)),
187 }
188 };
189 let home = home.map(canonical);
190 let home = home.as_deref();
191 let mut writable = vec![root.clone()];
192 writable.extend(
193 home.into_iter()
194 .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
195 );
196 writable.extend(
197 config
198 .writable
199 .iter()
200 .filter_map(|p| expand(p))
201 .map(|p| canonical(&p)),
202 );
203 let hidden = home
204 .into_iter()
205 .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
206 .collect();
207 let digest = {
208 use sha2::{Digest as _, Sha256};
209 let d = Sha256::digest(root.to_string_lossy().as_bytes());
210 d.iter()
211 .take(6)
212 .map(|b| format!("{b:02x}"))
213 .collect::<String>()
214 };
215 let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
217 Self {
218 writable,
219 hidden,
220 read_only: Vec::new(),
221 network: config.network,
222 tmp,
223 }
224 }
225
226 #[must_use]
229 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
230 self.read_only
231 .extend(read_only.iter().map(|p| canonical(p)));
232 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
233 self
234 }
235
236 #[must_use]
238 pub fn with_network(mut self, network: Network) -> Self {
239 self.network = network;
240 self
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Backend {
247 Seatbelt,
249 Landlock(PathBuf),
251}
252
253const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
254
255pub const HELPER_ARG: &str = "__sandbox";
257
258pub fn backend() -> Result<Backend, String> {
260 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
261 CACHED.get_or_init(detect).clone()
262}
263
264fn detect() -> Result<Backend, String> {
265 if cfg!(target_os = "macos") {
266 return if Path::new(SANDBOX_EXEC).exists() {
267 Ok(Backend::Seatbelt)
268 } else {
269 Err(format!("{SANDBOX_EXEC} is missing"))
270 };
271 }
272 if cfg!(target_os = "linux") {
273 let helper = std::env::var_os("SPECCODER_SANDBOX_HELPER")
274 .map(PathBuf::from)
275 .or_else(|| {
276 std::env::current_exe()
277 .ok()
278 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
279 })
280 .ok_or_else(|| "the speccoder helper binary was not found".to_owned())?;
281 return with_helper(helper);
282 }
283 Err(format!("no sandbox for {}", std::env::consts::OS))
284}
285
286pub fn with_helper(helper: PathBuf) -> Result<Backend, String> {
289 linux_supported()?;
290 Ok(Backend::Landlock(helper))
291}
292
293#[cfg(target_os = "linux")]
294fn linux_supported() -> Result<(), String> {
295 linux::probe()
296}
297
298#[cfg(not(target_os = "linux"))]
299fn linux_supported() -> Result<(), String> {
300 Err("not Linux".to_owned())
301}
302
303fn quote(p: &Path) -> String {
304 let s = p.to_string_lossy();
305 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
306}
307
308#[must_use]
310pub fn seatbelt(p: &Profile) -> String {
311 let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
312 out.push_str("(allow file-write*");
313 for w in p.writable.iter().chain([&p.tmp]) {
314 out.push_str(&format!(" (subpath {})", quote(w)));
315 }
316 out.push_str(
317 " (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
318 );
319 if !p.read_only.is_empty() {
320 out.push_str("(deny file-write*");
321 for r in &p.read_only {
322 out.push_str(&format!(" (subpath {})", quote(r)));
323 }
324 out.push_str(")\n");
325 }
326 if !p.hidden.is_empty() {
327 out.push_str("(deny file-read* file-write*");
328 for h in &p.hidden {
329 out.push_str(&format!(" (subpath {})", quote(h)));
330 }
331 out.push_str(")\n");
332 }
333 out.push_str("(deny appleevent-send)\n");
335 out.push_str("(deny mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\") (global-name \"com.apple.coreservices.launchservicesd\") (global-name \"com.apple.coreservices.appleevents\"))\n");
336 match p.network {
337 Network::On => {}
338 Network::Off | Network::Loopback => {
339 out.push_str("(deny network*)\n");
340 if p.network == Network::Loopback {
341 out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
344 }
345 out.push_str("(allow network-outbound network-bind network-inbound");
347 for w in p.writable.iter().chain([&p.tmp]) {
348 out.push_str(&format!(
349 " (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
350 q = quote(w)
351 ));
352 }
353 out.push_str(")\n");
354 }
355 }
356 out
357}
358
359pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
361 match backend {
362 Backend::Seatbelt => {
363 let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
364 out.extend_from_slice(argv);
365 Ok(out)
366 }
367 Backend::Landlock(helper) => {
368 let mut out = vec![
369 helper.to_string_lossy().into_owned(),
370 HELPER_ARG.to_owned(),
371 serde_json::to_string(p).map_err(|e| e.to_string())?,
372 "--".to_owned(),
373 ];
374 out.extend_from_slice(argv);
375 Ok(out)
376 }
377 }
378}
379
380#[derive(Debug, Clone)]
382pub struct Sandboxed {
383 pub profile: Profile,
385 pub backend: Backend,
387 pub network_allow: Vec<Vec<String>>,
389}
390
391impl Sandboxed {
392 #[must_use]
394 pub fn network_for(&self, argv: &[String]) -> Network {
395 if self.network_allow.iter().any(|p| argv.starts_with(p)) {
396 Network::On
397 } else {
398 self.profile.network
399 }
400 }
401
402 #[must_use]
404 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
405 let profile = self.profile.clone().with_network(network);
406 let failed = |e: String| Outcome {
407 exit_code: None,
408 timed_out: false,
409 spawn_error: Some(e),
410 started: std::time::SystemTime::now(),
411 duration: std::time::Duration::ZERO,
412 stdout: crate::verify::process::Captured::default(),
413 stderr: crate::verify::process::Captured::default(),
414 };
415 if let Some(program) = inv.argv.first()
418 && !resolvable(program, &inv.cwd, &inv.env)
419 {
420 return failed(format!(
421 "could not start `{program}`: No such file or directory"
422 ));
423 }
424 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
425 return failed(format!("the sandbox's temporary directory: {e}"));
426 }
427 let argv = match wrap(&profile, &self.backend, &inv.argv) {
428 Ok(a) => a,
429 Err(e) => return failed(e),
430 };
431 let tmp = profile.tmp.to_string_lossy().into_owned();
432 let mut env: Vec<(String, String)> = inv
433 .env
434 .iter()
435 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
436 .cloned()
437 .collect();
438 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
439 LocalRunner.run(&Invocation {
440 argv,
441 env,
442 ..inv.clone()
443 })
444 }
445}
446
447fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
449 if program.contains('/') {
450 return cwd.join(program).is_file();
451 }
452 let path = env
453 .iter()
454 .find(|(k, _)| k == "PATH")
455 .map(|(_, v)| v.as_str());
456 path.unwrap_or("/usr/bin:/bin")
457 .split(':')
458 .any(|d| Path::new(d).join(program).is_file())
459}
460
461impl CommandRunner for Sandboxed {
462 fn run(&self, inv: &Invocation) -> Outcome {
463 self.run_with_network(inv, self.network_for(&inv.argv))
464 }
465}
466
467pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
471 if config.mode == Mode::Off {
472 return Ok((
473 Runner::Plain,
474 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
475 ));
476 }
477 match backend() {
478 Ok(backend) => {
479 let home = std::env::var_os("HOME").map(PathBuf::from);
480 Ok((
481 Runner::Sandboxed(Sandboxed {
482 profile: Profile::new(root, config, home.as_deref()),
483 backend,
484 network_allow: config.network_allow.clone(),
485 }),
486 None,
487 ))
488 }
489 Err(e) if config.mode == Mode::Required => Err(format!(
490 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
491 )),
492 Err(e) => Ok((
493 Runner::Plain,
494 Some(format!("commands run unsandboxed: {e}")),
495 )),
496 }
497}
498
499pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
501 let text = crate::verify::find_config(root)
502 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
503 .unwrap_or_default();
504 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
505 runner(root, &config)
506}
507
508impl Runner {
509 #[must_use]
513 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
514 match self {
515 Self::Sandboxed(mut s) => {
516 if let Backend::Landlock(helper) = &s.backend
519 && !mounts_supported(helper)
520 {
521 return (
522 Self::Sandboxed(s),
523 Some("this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so commands can write `.git`, read ignored secrets in place and reach daemon sockets such as Docker's; the run stops if `.git` changes".to_owned()),
524 );
525 }
526 s.profile = s.profile.carve(read_only, hidden);
527 (Self::Sandboxed(s), None)
528 }
529 Self::Plain => (Self::Plain, None),
530 }
531 }
532}
533
534#[derive(Debug, Clone)]
536pub enum Runner {
537 Sandboxed(Sandboxed),
539 Plain,
541}
542
543impl Runner {
544 #[must_use]
546 pub fn is_sandboxed(&self) -> bool {
547 matches!(self, Self::Sandboxed(_))
548 }
549
550 #[must_use]
552 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
553 match self {
554 Self::Plain => LocalRunner.run(inv),
555 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
556 Self::Sandboxed(s) => s.run(inv),
557 }
558 }
559
560 #[must_use]
563 pub fn scratch(&self) -> Option<PathBuf> {
564 match self {
565 Self::Plain => None,
566 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
567 .ok()
568 .map(|()| s.profile.tmp.clone()),
569 }
570 }
571
572 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
574 match self {
575 Self::Plain => Ok((argv.to_vec(), None)),
576 Self::Sandboxed(s) => {
577 let profile = s.profile.clone().with_network(s.network_for(argv));
578 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
579 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
580 }
581 }
582 }
583}
584
585impl CommandRunner for Runner {
586 fn run(&self, inv: &Invocation) -> Outcome {
587 self.run_with(inv, false)
588 }
589}
590
591pub fn helper_main(args: &[String]) -> String {
594 if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
595 return match probe_mounts() {
596 Ok(()) => std::process::exit(0),
597 Err(e) => e,
598 };
599 }
600 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
601 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
602 };
603 let profile: Profile = match serde_json::from_str(profile) {
604 Ok(p) => p,
605 Err(e) => return format!("the sandbox profile: {e}"),
606 };
607 let argv = &args[2..];
608 if argv.is_empty() {
609 return "nothing to run".to_owned();
610 }
611 enter(&profile, argv)
612}
613
614#[cfg(target_os = "linux")]
615fn enter(profile: &Profile, argv: &[String]) -> String {
616 linux::enter(profile, argv)
617}
618
619const PROBE_MOUNTS: &str = "--probe-mounts";
621
622fn mounts_supported(helper: &Path) -> bool {
625 static CACHED: OnceLock<bool> = OnceLock::new();
626 *CACHED.get_or_init(|| {
627 std::process::Command::new(helper)
628 .args([HELPER_ARG, PROBE_MOUNTS])
629 .stdout(std::process::Stdio::null())
630 .stderr(std::process::Stdio::null())
631 .status()
632 .is_ok_and(|s| s.success())
633 })
634}
635
636#[cfg(target_os = "linux")]
637fn probe_mounts() -> Result<(), String> {
638 linux::probe_mounts()
639}
640
641#[cfg(not(target_os = "linux"))]
642fn probe_mounts() -> Result<(), String> {
643 Err("mount carve-outs are for Linux".to_owned())
644}
645
646#[cfg(not(target_os = "linux"))]
647fn enter(_profile: &Profile, _argv: &[String]) -> String {
648 format!("the {HELPER_ARG} helper is for Linux")
649}
650
651#[cfg(target_os = "linux")]
652mod linux {
653 use std::collections::BTreeMap;
656 use std::path::{Path, PathBuf};
657
658 use landlock::{
659 ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
660 RulesetCreatedAttr, RulesetStatus,
661 };
662 use seccompiler::{
663 SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
664 SeccompRule, TargetArch,
665 };
666
667 use super::{Network, Profile};
668
669 const ABI_LEVEL: ABI = ABI::V3;
670
671 pub(super) fn probe() -> Result<(), String> {
672 Ruleset::default()
673 .set_compatibility(CompatLevel::HardRequirement)
674 .handle_access(AccessFs::from_all(ABI_LEVEL))
675 .and_then(|r| r.create())
676 .map(|_| ())
677 .map_err(|e| format!("Landlock is not available: {e}"))
678 }
679
680 fn mount_carve(profile: &Profile) -> Result<(), String> {
686 use nix::mount::{MsFlags, mount};
687 let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
688 let hidden: Vec<&PathBuf> = profile
689 .hidden
690 .iter()
691 .filter(|h| inside(h) && h.exists())
692 .collect();
693 let sockets: Vec<PathBuf> = daemon_sockets()
694 .into_iter()
695 .filter(|p| !inside(p))
696 .collect();
697 let carved = !profile.read_only.is_empty() || !hidden.is_empty();
698 if !carved && sockets.is_empty() {
699 return Ok(());
700 }
701 if let Err(e) = private_namespace() {
705 return if carved { Err(e) } else { Ok(()) };
706 }
707 let none: Option<&str> = None;
708 for p in profile.read_only.iter().filter(|p| p.exists()) {
709 mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
710 .map_err(|e| format!("bind {}: {e}", p.display()))?;
711 mount(
712 none,
713 p,
714 none,
715 MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
716 none,
717 )
718 .map_err(|e| format!("read-only {}: {e}", p.display()))?;
719 }
720 for h in hidden {
721 if h.is_dir() {
722 mount(
723 Some("tmpfs"),
724 h,
725 Some("tmpfs"),
726 MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
727 Some("size=4k"),
728 )
729 } else {
730 mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
731 }
732 .map_err(|e| format!("hide {}: {e}", h.display()))?;
733 }
734 for p in &sockets {
738 mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
739 .map_err(|e| format!("cover {}: {e}", p.display()))?;
740 }
741 Ok(())
742 }
743
744 fn daemon_sockets() -> Vec<PathBuf> {
747 use std::os::unix::fs::FileTypeExt as _;
748 fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
749 let Ok(entries) = std::fs::read_dir(dir) else {
750 return;
751 };
752 for e in entries.flatten() {
753 let Ok(t) = e.file_type() else { continue };
754 if t.is_socket() {
755 out.push(e.path());
756 } else if t.is_dir() && depth > 0 {
757 walk(&e.path(), depth - 1, out);
758 }
759 }
760 }
761 let mut roots = vec![
762 PathBuf::from("/run"),
763 PathBuf::from("/var/run"),
764 PathBuf::from("/tmp"),
765 ];
766 if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
767 roots.push(PathBuf::from(x));
768 }
769 let mut seen = std::collections::BTreeSet::new();
770 let mut out = Vec::new();
771 for root in roots {
772 let Ok(real) = root.canonicalize() else {
774 continue;
775 };
776 if !seen.insert(real.clone()) {
777 continue;
778 }
779 walk(&real, 3, &mut out);
780 }
781 out.sort();
782 out.dedup();
783 out
784 }
785
786 fn private_namespace() -> Result<(), String> {
789 use nix::mount::{MsFlags, mount};
790 use nix::sched::{CloneFlags, unshare};
791 let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
792 unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
793 .map_err(|e| format!("user namespace: {e}"))?;
794 let write = |file: &str, text: String| {
795 std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
796 };
797 write("/proc/self/setgroups", "deny".to_owned())?;
798 write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
799 write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
800 let none: Option<&str> = None;
801 mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
802 .map_err(|e| format!("private mounts: {e}"))
803 }
804
805 fn locked(p: &Path) -> nix::mount::MsFlags {
807 use nix::mount::MsFlags;
808 use nix::sys::statvfs::{FsFlags, statvfs};
809 let Ok(st) = statvfs(p) else {
810 return MsFlags::empty();
811 };
812 let f = st.flags();
813 let mut out = MsFlags::empty();
814 for (fs, ms) in [
815 (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
816 (FsFlags::ST_NODEV, MsFlags::MS_NODEV),
817 (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
818 (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
819 (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
820 (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
821 ] {
822 if f.contains(fs) {
823 out |= ms;
824 }
825 }
826 out
827 }
828
829 pub(super) fn probe_mounts() -> Result<(), String> {
832 let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
833 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
834 let profile = Profile {
835 writable: Vec::new(),
836 hidden: Vec::new(),
837 read_only: vec![dir.clone()],
838 network: Network::On,
839 tmp: dir.clone(),
840 };
841 let carved = mount_carve(&profile);
842 let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
843 let _ = std::fs::remove_dir_all(&dir);
844 carved.and_then(|()| {
845 refused
846 .then_some(())
847 .ok_or_else(|| "the read-only bind did not hold".to_owned())
848 })
849 }
850
851 fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
854 if hidden.iter().any(|h| h == dir) {
855 return;
856 }
857 if !hidden.iter().any(|h| h.starts_with(dir)) {
858 out.push(dir.to_path_buf());
859 return;
860 }
861 if let Ok(entries) = std::fs::read_dir(dir) {
862 for e in entries.flatten() {
863 readable(&e.path(), hidden, out);
864 }
865 }
866 }
867
868 pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
869 match mount_carve(profile).and_then(|()| confine(profile)) {
870 Ok(()) => {
871 use std::os::unix::process::CommandExt as _;
872 let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
873 format!("could not start `{}`: {e}", argv[0])
874 }
875 Err(e) => format!("the sandbox could not be applied: {e}"),
876 }
877 }
878
879 fn confine(profile: &Profile) -> Result<(), String> {
880 let read = AccessFs::from_read(ABI_LEVEL);
881 let all = AccessFs::from_all(ABI_LEVEL);
882 let mut readable_paths = Vec::new();
883 readable(Path::new("/"), &profile.hidden, &mut readable_paths);
884 let mut ruleset = Ruleset::default()
885 .set_compatibility(CompatLevel::HardRequirement)
886 .handle_access(all)
887 .and_then(|r| r.create())
888 .map_err(|e| e.to_string())?;
889 for p in &readable_paths {
890 if let Ok(fd) = PathFd::new(p) {
891 let access = if p.is_dir() {
892 read
893 } else {
894 read & AccessFs::from_file(ABI_LEVEL)
895 };
896 ruleset = ruleset
897 .add_rule(PathBeneath::new(fd, access))
898 .map_err(|e| e.to_string())?;
899 }
900 }
901 for w in profile.writable.iter().chain([&profile.tmp]) {
902 if let Ok(fd) = PathFd::new(w) {
903 let access = if w.is_dir() {
904 all
905 } else {
906 AccessFs::from_file(ABI_LEVEL)
907 };
908 ruleset = ruleset
909 .add_rule(PathBeneath::new(fd, access))
910 .map_err(|e| e.to_string())?;
911 }
912 }
913 for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
914 if let Ok(fd) = PathFd::new(dev) {
915 ruleset = ruleset
916 .add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
917 .map_err(|e| e.to_string())?;
918 }
919 }
920 let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
921 if status.ruleset != RulesetStatus::FullyEnforced {
922 return Err(format!("Landlock only {:?}", status.ruleset));
923 }
924 if profile.network != Network::On {
925 no_inet()?;
926 }
927 Ok(())
928 }
929
930 fn no_inet() -> Result<(), String> {
932 let rule = |domain: u64| {
933 SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
934 .and_then(|c| SeccompRule::new(vec![c]))
935 .map_err(|e| e.to_string())
936 };
937 let rules = vec![
938 rule(u64::from(libc::AF_INET.unsigned_abs()))?,
939 rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
940 ];
941 let arch: TargetArch = std::env::consts::ARCH
942 .try_into()
943 .map_err(|e: seccompiler::BackendError| e.to_string())?;
944 let filter = SeccompFilter::new(
945 BTreeMap::from([(libc::SYS_socket, rules)]),
946 SeccompAction::Allow,
947 SeccompAction::Errno(libc::EACCES.unsigned_abs()),
948 arch,
949 )
950 .map_err(|e| e.to_string())?;
951 let program: seccompiler::BpfProgram = filter
952 .try_into()
953 .map_err(|e: seccompiler::BackendError| e.to_string())?;
954 seccompiler::apply_filter(&program).map_err(|e| e.to_string())
955 }
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961
962 #[test]
963 fn config_parses_and_defaults_to_loopback() {
964 assert_eq!(parse("").unwrap_or_default(), Config::default());
965 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
966 .unwrap_or_default();
967 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
968 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
969 }
970
971 #[test]
972 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
973 let home = Path::new("/home/u");
974 let p = Profile::new(
975 Path::new("/s/proj/worktrees/001"),
976 &Config::default(),
977 Some(home),
978 );
979 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
980 assert!(
981 !p.writable
982 .iter()
983 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
984 );
985 assert!(p.hidden.contains(&home.join(".ssh")));
986 let sb = seatbelt(&p);
987 assert!(sb.contains("(deny network*)"));
988 assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
989 assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
990 }
991}