1use std::path::{Path, PathBuf};
21use std::sync::OnceLock;
22
23use serde::{Deserialize, Serialize};
24
25use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Network {
31 Off,
33 Loopback,
36 On,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43 Auto,
45 Required,
47 Off,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Config {
54 pub mode: Mode,
56 pub network: Network,
58 pub network_allow: Vec<Vec<String>>,
60 pub network_allow_prefix: Vec<Vec<String>>,
62 pub writable: Vec<String>,
64}
65
66impl Default for Config {
67 fn default() -> Self {
68 Self {
69 mode: Mode::Auto,
70 network: Network::Loopback,
71 network_allow: Vec::new(),
72 network_allow_prefix: Vec::new(),
73 writable: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(deny_unknown_fields)]
80struct Raw {
81 mode: Option<String>,
82 network: Option<String>,
83 #[serde(default)]
84 network_allow: Vec<Vec<String>>,
85 #[serde(default)]
86 network_allow_prefix: Vec<Vec<String>>,
87 #[serde(default)]
88 writable: Vec<String>,
89}
90
91pub fn parse(text: &str) -> Result<Config, String> {
93 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
94 let Some(v) = table.get("sandbox") else {
95 return Ok(Config::default());
96 };
97 let raw: Raw = v
98 .clone()
99 .try_into()
100 .map_err(|e| format!("[sandbox]: {e}"))?;
101 let mode = match raw.mode.as_deref() {
102 None | Some("auto") => Mode::Auto,
103 Some("required") => Mode::Required,
104 Some("off") => Mode::Off,
105 Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
106 };
107 let network = match raw.network.as_deref() {
108 None | Some("loopback") => Network::Loopback,
109 Some("off") => Network::Off,
110 Some("on") => Network::On,
111 Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
112 };
113 if let Some(why) = raw
114 .network_allow_prefix
115 .iter()
116 .find_map(|p| crate::tools::policy::prefix_refusal(p))
117 {
118 return Err(format!("[sandbox] network_allow_prefix: {why}"));
119 }
120 Ok(Config {
121 mode,
122 network,
123 network_allow: raw
124 .network_allow
125 .into_iter()
126 .filter(|p| !p.is_empty())
127 .collect(),
128 network_allow_prefix: raw.network_allow_prefix,
129 writable: raw.writable,
130 })
131}
132
133pub const HIDDEN: &[&str] = &[
135 ".ssh",
136 ".aws",
137 ".azure",
138 ".gnupg",
139 ".netrc",
140 ".docker",
141 ".kube",
142 ".npmrc",
143 ".pypirc",
144 ".git-credentials",
145 ".password-store",
146 ".config/gh",
147 ".config/gcloud",
148 ".config/hub",
149 ".cargo/credentials",
150 ".cargo/credentials.toml",
151 ".local/share/keyrings",
152 "Library/Keychains",
153];
154
155pub const CACHES: &[&str] = &[
158 ".cargo/registry",
159 ".cargo/git",
160 ".cargo/.package-cache",
161 ".cargo/.package-cache-mutate",
162 ".cargo/.global-cache",
163 ".cache",
164 ".npm",
165 ".pnpm-store",
166 ".yarn/berry/cache",
167 "go/pkg/mod",
168 "Library/Caches",
169];
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Profile {
174 pub writable: Vec<PathBuf>,
176 pub hidden: Vec<PathBuf>,
178 pub read_only: Vec<PathBuf>,
181 pub network: Network,
183 pub tmp: PathBuf,
185}
186
187fn canonical(p: &Path) -> PathBuf {
188 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
189}
190
191impl Profile {
192 #[must_use]
194 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
195 let root = canonical(root);
196 let expand = |p: &str| -> Option<PathBuf> {
197 match (p.strip_prefix("~/"), home) {
198 (Some(rest), Some(h)) => Some(h.join(rest)),
199 (Some(_), None) => None,
200 (None, _) => Some(PathBuf::from(p)),
201 }
202 };
203 let home = home.map(canonical);
204 let home = home.as_deref();
205 let mut writable = vec![root.clone()];
206 writable.extend(
207 home.into_iter()
208 .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
209 );
210 writable.extend(
211 config
212 .writable
213 .iter()
214 .filter_map(|p| expand(p))
215 .map(|p| canonical(&p)),
216 );
217 let hidden = home
218 .into_iter()
219 .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
220 .collect();
221 let digest = {
222 use sha2::{Digest as _, Sha256};
223 let d = Sha256::digest(root.to_string_lossy().as_bytes());
224 d.iter()
225 .take(6)
226 .map(|b| format!("{b:02x}"))
227 .collect::<String>()
228 };
229 let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
231 Self {
232 writable,
233 hidden,
234 read_only: Vec::new(),
235 network: config.network,
236 tmp,
237 }
238 }
239
240 #[must_use]
243 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
244 self.read_only
245 .extend(read_only.iter().map(|p| canonical(p)));
246 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
247 self
248 }
249
250 #[must_use]
252 pub fn with_network(mut self, network: Network) -> Self {
253 self.network = network;
254 self
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Backend {
261 Seatbelt,
263 Landlock(PathBuf),
265}
266
267const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
268
269pub const HELPER_ARG: &str = "__sandbox";
271
272pub fn backend() -> Result<Backend, String> {
274 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
275 CACHED.get_or_init(detect).clone()
276}
277
278fn detect() -> Result<Backend, String> {
279 if cfg!(target_os = "macos") {
280 return if Path::new(SANDBOX_EXEC).exists() {
281 Ok(Backend::Seatbelt)
282 } else {
283 Err(format!("{SANDBOX_EXEC} is missing"))
284 };
285 }
286 if cfg!(target_os = "linux") {
287 let helper = std::env::var_os("SPECCODER_SANDBOX_HELPER")
288 .map(PathBuf::from)
289 .or_else(|| {
290 std::env::current_exe()
291 .ok()
292 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
293 })
294 .ok_or_else(|| "the speccoder helper binary was not found".to_owned())?;
295 return with_helper(helper);
296 }
297 Err(format!("no sandbox for {}", std::env::consts::OS))
298}
299
300pub fn with_helper(helper: PathBuf) -> Result<Backend, String> {
303 linux_supported()?;
304 Ok(Backend::Landlock(helper))
305}
306
307#[cfg(target_os = "linux")]
308fn linux_supported() -> Result<(), String> {
309 linux::probe()
310}
311
312#[cfg(not(target_os = "linux"))]
313fn linux_supported() -> Result<(), String> {
314 Err("not Linux".to_owned())
315}
316
317fn quote(p: &Path) -> String {
318 let s = p.to_string_lossy();
319 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
320}
321
322#[must_use]
324pub fn seatbelt(p: &Profile) -> String {
325 let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
326 out.push_str("(allow file-write*");
327 for w in p.writable.iter().chain([&p.tmp]) {
328 out.push_str(&format!(" (subpath {})", quote(w)));
329 }
330 out.push_str(
331 " (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
332 );
333 if !p.read_only.is_empty() {
334 out.push_str("(deny file-write*");
335 for r in &p.read_only {
336 out.push_str(&format!(" (subpath {})", quote(r)));
337 }
338 out.push_str(")\n");
339 }
340 if !p.hidden.is_empty() {
341 out.push_str("(deny file-read* file-write*");
342 for h in &p.hidden {
343 out.push_str(&format!(" (subpath {})", quote(h)));
344 }
345 out.push_str(")\n");
346 }
347 out.push_str("(deny appleevent-send)\n");
349 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");
350 match p.network {
351 Network::On => {}
352 Network::Off | Network::Loopback => {
353 out.push_str("(deny network*)\n");
354 if p.network == Network::Loopback {
355 out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
358 }
359 out.push_str("(allow network-outbound network-bind network-inbound");
361 for w in p.writable.iter().chain([&p.tmp]) {
362 out.push_str(&format!(
363 " (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
364 q = quote(w)
365 ));
366 }
367 out.push_str(")\n");
368 }
369 }
370 out
371}
372
373pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
375 match backend {
376 Backend::Seatbelt => {
377 let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
378 out.extend_from_slice(argv);
379 Ok(out)
380 }
381 Backend::Landlock(helper) => {
382 let mut out = vec![
383 helper.to_string_lossy().into_owned(),
384 HELPER_ARG.to_owned(),
385 serde_json::to_string(p).map_err(|e| e.to_string())?,
386 "--".to_owned(),
387 ];
388 out.extend_from_slice(argv);
389 Ok(out)
390 }
391 }
392}
393
394#[derive(Debug, Clone)]
396pub struct Sandboxed {
397 pub profile: Profile,
399 pub backend: Backend,
401 pub network_allow: Vec<Vec<String>>,
403 pub network_allow_prefix: Vec<Vec<String>>,
405}
406
407impl Sandboxed {
408 #[must_use]
411 pub fn network_for(&self, argv: &[String]) -> Network {
412 if self.network_allow.iter().any(|p| p.as_slice() == argv)
413 || self
414 .network_allow_prefix
415 .iter()
416 .any(|p| crate::tools::policy::prefix_matches(p, argv))
417 {
418 Network::On
419 } else {
420 self.profile.network
421 }
422 }
423
424 #[must_use]
426 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
427 let profile = self.profile.clone().with_network(network);
428 let failed = |e: String| Outcome {
429 exit_code: None,
430 timed_out: false,
431 spawn_error: Some(e),
432 started: std::time::SystemTime::now(),
433 duration: std::time::Duration::ZERO,
434 stdout: crate::verify::process::Captured::default(),
435 stderr: crate::verify::process::Captured::default(),
436 };
437 if let Some(program) = inv.argv.first()
440 && !resolvable(program, &inv.cwd, &inv.env)
441 {
442 return failed(format!(
443 "could not start `{program}`: No such file or directory"
444 ));
445 }
446 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
447 return failed(format!("the sandbox's temporary directory: {e}"));
448 }
449 let argv = match wrap(&profile, &self.backend, &inv.argv) {
450 Ok(a) => a,
451 Err(e) => return failed(e),
452 };
453 let tmp = profile.tmp.to_string_lossy().into_owned();
454 let mut env: Vec<(String, String)> = inv
455 .env
456 .iter()
457 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
458 .cloned()
459 .collect();
460 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
461 LocalRunner.run(&Invocation {
462 argv,
463 env,
464 ..inv.clone()
465 })
466 }
467}
468
469#[must_use]
476pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
477 let candidate = |p: PathBuf| {
478 executable_file(&p)
479 .then(|| std::fs::canonicalize(&p).ok())
480 .flatten()
481 };
482 if program.is_empty() {
483 return None;
484 }
485 if program.contains('/') {
486 return candidate(cwd.join(program));
487 }
488 let path = env
489 .iter()
490 .find(|(k, _)| k == "PATH")
491 .map(|(_, v)| v.as_str());
492 path.unwrap_or("/usr/bin:/bin")
493 .split(':')
494 .filter(|d| !d.is_empty())
495 .find_map(|d| candidate(cwd.join(d).join(program)))
496}
497
498fn executable_file(p: &Path) -> bool {
500 #[cfg(unix)]
501 {
502 use std::os::unix::fs::PermissionsExt as _;
503 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
504 }
505 #[cfg(not(unix))]
506 {
507 p.is_file()
508 }
509}
510
511fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
513 resolve_executable(program, cwd, env).is_some()
514}
515
516impl CommandRunner for Sandboxed {
517 fn run(&self, inv: &Invocation) -> Outcome {
518 self.run_with_network(inv, self.network_for(&inv.argv))
519 }
520}
521
522pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
526 if config.mode == Mode::Off {
527 return Ok((
528 Runner::Plain,
529 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
530 ));
531 }
532 match backend() {
533 Ok(backend) => {
534 let home = std::env::var_os("HOME").map(PathBuf::from);
535 Ok((
536 Runner::Sandboxed(Sandboxed {
537 profile: Profile::new(root, config, home.as_deref()),
538 backend,
539 network_allow: config.network_allow.clone(),
540 network_allow_prefix: config.network_allow_prefix.clone(),
541 }),
542 None,
543 ))
544 }
545 Err(e) if config.mode == Mode::Required => Err(format!(
546 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
547 )),
548 Err(e) => Ok((
549 Runner::Plain,
550 Some(format!("commands run unsandboxed: {e}")),
551 )),
552 }
553}
554
555pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
557 let text = crate::verify::find_config(root)
558 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
559 .unwrap_or_default();
560 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
561 runner(root, &config)
562}
563
564impl Runner {
565 #[must_use]
569 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
570 match self {
571 Self::Sandboxed(mut s) => {
572 if let Backend::Landlock(helper) = &s.backend
575 && !mounts_supported(helper)
576 {
577 return (
578 Self::Sandboxed(s),
579 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()),
580 );
581 }
582 s.profile = s.profile.carve(read_only, hidden);
583 (Self::Sandboxed(s), None)
584 }
585 Self::Plain => (Self::Plain, None),
586 }
587 }
588}
589
590#[derive(Debug, Clone)]
592pub enum Runner {
593 Sandboxed(Sandboxed),
595 Plain,
597}
598
599impl Runner {
600 #[must_use]
602 pub fn is_sandboxed(&self) -> bool {
603 matches!(self, Self::Sandboxed(_))
604 }
605
606 #[must_use]
608 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
609 match self {
610 Self::Plain => LocalRunner.run(inv),
611 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
612 Self::Sandboxed(s) => s.run(inv),
613 }
614 }
615
616 #[must_use]
619 pub fn scratch(&self) -> Option<PathBuf> {
620 match self {
621 Self::Plain => None,
622 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
623 .ok()
624 .map(|()| s.profile.tmp.clone()),
625 }
626 }
627
628 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
630 match self {
631 Self::Plain => Ok((argv.to_vec(), None)),
632 Self::Sandboxed(s) => {
633 let profile = s.profile.clone().with_network(s.network_for(argv));
634 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
635 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
636 }
637 }
638 }
639}
640
641impl CommandRunner for Runner {
642 fn run(&self, inv: &Invocation) -> Outcome {
643 self.run_with(inv, false)
644 }
645}
646
647pub fn helper_main(args: &[String]) -> String {
650 if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
651 return match probe_mounts() {
652 Ok(()) => std::process::exit(0),
653 Err(e) => e,
654 };
655 }
656 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
657 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
658 };
659 let profile: Profile = match serde_json::from_str(profile) {
660 Ok(p) => p,
661 Err(e) => return format!("the sandbox profile: {e}"),
662 };
663 let argv = &args[2..];
664 if argv.is_empty() {
665 return "nothing to run".to_owned();
666 }
667 enter(&profile, argv)
668}
669
670#[cfg(target_os = "linux")]
671fn enter(profile: &Profile, argv: &[String]) -> String {
672 linux::enter(profile, argv)
673}
674
675const PROBE_MOUNTS: &str = "--probe-mounts";
677
678fn mounts_supported(helper: &Path) -> bool {
681 static CACHED: OnceLock<bool> = OnceLock::new();
682 *CACHED.get_or_init(|| {
683 std::process::Command::new(helper)
684 .args([HELPER_ARG, PROBE_MOUNTS])
685 .stdout(std::process::Stdio::null())
686 .stderr(std::process::Stdio::null())
687 .status()
688 .is_ok_and(|s| s.success())
689 })
690}
691
692#[cfg(target_os = "linux")]
693fn probe_mounts() -> Result<(), String> {
694 linux::probe_mounts()
695}
696
697#[cfg(not(target_os = "linux"))]
698fn probe_mounts() -> Result<(), String> {
699 Err("mount carve-outs are for Linux".to_owned())
700}
701
702#[cfg(not(target_os = "linux"))]
703fn enter(_profile: &Profile, _argv: &[String]) -> String {
704 format!("the {HELPER_ARG} helper is for Linux")
705}
706
707#[cfg(target_os = "linux")]
708mod linux {
709 use std::collections::BTreeMap;
712 use std::path::{Path, PathBuf};
713
714 use landlock::{
715 ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
716 RulesetCreatedAttr, RulesetStatus,
717 };
718 use seccompiler::{
719 SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
720 SeccompRule, TargetArch,
721 };
722
723 use super::{Network, Profile};
724
725 const ABI_LEVEL: ABI = ABI::V3;
726
727 pub(super) fn probe() -> Result<(), String> {
728 Ruleset::default()
729 .set_compatibility(CompatLevel::HardRequirement)
730 .handle_access(AccessFs::from_all(ABI_LEVEL))
731 .and_then(|r| r.create())
732 .map(|_| ())
733 .map_err(|e| format!("Landlock is not available: {e}"))
734 }
735
736 fn mount_carve(profile: &Profile) -> Result<(), String> {
742 use nix::mount::{MsFlags, mount};
743 let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
744 let hidden: Vec<&PathBuf> = profile
745 .hidden
746 .iter()
747 .filter(|h| inside(h) && h.exists())
748 .collect();
749 let sockets: Vec<PathBuf> = daemon_sockets()
750 .into_iter()
751 .filter(|p| !inside(p))
752 .collect();
753 let carved = !profile.read_only.is_empty() || !hidden.is_empty();
754 if !carved && sockets.is_empty() {
755 return Ok(());
756 }
757 if let Err(e) = private_namespace() {
761 return if carved { Err(e) } else { Ok(()) };
762 }
763 let none: Option<&str> = None;
764 for p in profile.read_only.iter().filter(|p| p.exists()) {
765 mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
766 .map_err(|e| format!("bind {}: {e}", p.display()))?;
767 mount(
768 none,
769 p,
770 none,
771 MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
772 none,
773 )
774 .map_err(|e| format!("read-only {}: {e}", p.display()))?;
775 }
776 for h in hidden {
777 if h.is_dir() {
778 mount(
779 Some("tmpfs"),
780 h,
781 Some("tmpfs"),
782 MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
783 Some("size=4k"),
784 )
785 } else {
786 mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
787 }
788 .map_err(|e| format!("hide {}: {e}", h.display()))?;
789 }
790 for p in &sockets {
794 mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
795 .map_err(|e| format!("cover {}: {e}", p.display()))?;
796 }
797 Ok(())
798 }
799
800 fn daemon_sockets() -> Vec<PathBuf> {
803 use std::os::unix::fs::FileTypeExt as _;
804 fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
805 let Ok(entries) = std::fs::read_dir(dir) else {
806 return;
807 };
808 for e in entries.flatten() {
809 let Ok(t) = e.file_type() else { continue };
810 if t.is_socket() {
811 out.push(e.path());
812 } else if t.is_dir() && depth > 0 {
813 walk(&e.path(), depth - 1, out);
814 }
815 }
816 }
817 let mut roots = vec![
818 PathBuf::from("/run"),
819 PathBuf::from("/var/run"),
820 PathBuf::from("/tmp"),
821 ];
822 if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
823 roots.push(PathBuf::from(x));
824 }
825 let mut seen = std::collections::BTreeSet::new();
826 let mut out = Vec::new();
827 for root in roots {
828 let Ok(real) = root.canonicalize() else {
830 continue;
831 };
832 if !seen.insert(real.clone()) {
833 continue;
834 }
835 walk(&real, 3, &mut out);
836 }
837 out.sort();
838 out.dedup();
839 out
840 }
841
842 fn private_namespace() -> Result<(), String> {
845 use nix::mount::{MsFlags, mount};
846 use nix::sched::{CloneFlags, unshare};
847 let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
848 unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
849 .map_err(|e| format!("user namespace: {e}"))?;
850 let write = |file: &str, text: String| {
851 std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
852 };
853 write("/proc/self/setgroups", "deny".to_owned())?;
854 write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
855 write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
856 let none: Option<&str> = None;
857 mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
858 .map_err(|e| format!("private mounts: {e}"))
859 }
860
861 fn locked(p: &Path) -> nix::mount::MsFlags {
863 use nix::mount::MsFlags;
864 use nix::sys::statvfs::{FsFlags, statvfs};
865 let Ok(st) = statvfs(p) else {
866 return MsFlags::empty();
867 };
868 let f = st.flags();
869 let mut out = MsFlags::empty();
870 for (fs, ms) in [
871 (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
872 (FsFlags::ST_NODEV, MsFlags::MS_NODEV),
873 (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
874 (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
875 (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
876 (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
877 ] {
878 if f.contains(fs) {
879 out |= ms;
880 }
881 }
882 out
883 }
884
885 pub(super) fn probe_mounts() -> Result<(), String> {
888 let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
889 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
890 let profile = Profile {
891 writable: Vec::new(),
892 hidden: Vec::new(),
893 read_only: vec![dir.clone()],
894 network: Network::On,
895 tmp: dir.clone(),
896 };
897 let carved = mount_carve(&profile);
898 let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
899 let _ = std::fs::remove_dir_all(&dir);
900 carved.and_then(|()| {
901 refused
902 .then_some(())
903 .ok_or_else(|| "the read-only bind did not hold".to_owned())
904 })
905 }
906
907 fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
910 if hidden.iter().any(|h| h == dir) {
911 return;
912 }
913 if !hidden.iter().any(|h| h.starts_with(dir)) {
914 out.push(dir.to_path_buf());
915 return;
916 }
917 if let Ok(entries) = std::fs::read_dir(dir) {
918 for e in entries.flatten() {
919 readable(&e.path(), hidden, out);
920 }
921 }
922 }
923
924 pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
925 match mount_carve(profile).and_then(|()| confine(profile)) {
926 Ok(()) => {
927 use std::os::unix::process::CommandExt as _;
928 let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
929 format!("could not start `{}`: {e}", argv[0])
930 }
931 Err(e) => format!("the sandbox could not be applied: {e}"),
932 }
933 }
934
935 fn confine(profile: &Profile) -> Result<(), String> {
936 let read = AccessFs::from_read(ABI_LEVEL);
937 let all = AccessFs::from_all(ABI_LEVEL);
938 let mut readable_paths = Vec::new();
939 readable(Path::new("/"), &profile.hidden, &mut readable_paths);
940 let mut ruleset = Ruleset::default()
941 .set_compatibility(CompatLevel::HardRequirement)
942 .handle_access(all)
943 .and_then(|r| r.create())
944 .map_err(|e| e.to_string())?;
945 for p in &readable_paths {
946 if let Ok(fd) = PathFd::new(p) {
947 let access = if p.is_dir() {
948 read
949 } else {
950 read & AccessFs::from_file(ABI_LEVEL)
951 };
952 ruleset = ruleset
953 .add_rule(PathBeneath::new(fd, access))
954 .map_err(|e| e.to_string())?;
955 }
956 }
957 for w in profile.writable.iter().chain([&profile.tmp]) {
958 if let Ok(fd) = PathFd::new(w) {
959 let access = if w.is_dir() {
960 all
961 } else {
962 AccessFs::from_file(ABI_LEVEL)
963 };
964 ruleset = ruleset
965 .add_rule(PathBeneath::new(fd, access))
966 .map_err(|e| e.to_string())?;
967 }
968 }
969 for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
970 if let Ok(fd) = PathFd::new(dev) {
971 ruleset = ruleset
972 .add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
973 .map_err(|e| e.to_string())?;
974 }
975 }
976 let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
977 if status.ruleset != RulesetStatus::FullyEnforced {
978 return Err(format!("Landlock only {:?}", status.ruleset));
979 }
980 if profile.network != Network::On {
981 no_inet()?;
982 }
983 Ok(())
984 }
985
986 fn no_inet() -> Result<(), String> {
988 let rule = |domain: u64| {
989 SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
990 .and_then(|c| SeccompRule::new(vec![c]))
991 .map_err(|e| e.to_string())
992 };
993 let rules = vec![
994 rule(u64::from(libc::AF_INET.unsigned_abs()))?,
995 rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
996 ];
997 let arch: TargetArch = std::env::consts::ARCH
998 .try_into()
999 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1000 let filter = SeccompFilter::new(
1001 BTreeMap::from([(libc::SYS_socket, rules)]),
1002 SeccompAction::Allow,
1003 SeccompAction::Errno(libc::EACCES.unsigned_abs()),
1004 arch,
1005 )
1006 .map_err(|e| e.to_string())?;
1007 let program: seccompiler::BpfProgram = filter
1008 .try_into()
1009 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1010 seccompiler::apply_filter(&program).map_err(|e| e.to_string())
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017
1018 #[test]
1019 fn config_parses_and_defaults_to_loopback() {
1020 assert_eq!(parse("").unwrap_or_default(), Config::default());
1021 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
1022 .unwrap_or_default();
1023 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
1024 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
1025 }
1026
1027 #[test]
1028 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
1029 let home = Path::new("/home/u");
1030 let p = Profile::new(
1031 Path::new("/s/proj/worktrees/001"),
1032 &Config::default(),
1033 Some(home),
1034 );
1035 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
1036 assert!(
1037 !p.writable
1038 .iter()
1039 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
1040 );
1041 assert!(p.hidden.contains(&home.join(".ssh")));
1042 let sb = seatbelt(&p);
1043 assert!(sb.contains("(deny network*)"));
1044 assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
1045 assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
1046 }
1047
1048 #[test]
1049 fn the_executable_is_the_first_on_the_path_canonicalised() {
1050 use std::os::unix::fs::PermissionsExt;
1051 let dir = tempfile::tempdir().unwrap();
1052 let root = std::fs::canonicalize(dir.path()).unwrap();
1053 for d in ["bin", "usr"] {
1054 std::fs::create_dir_all(root.join(d)).unwrap();
1055 let f = root.join(d).join("make");
1056 std::fs::write(&f, "#!/bin/sh\n").unwrap();
1057 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
1058 }
1059 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
1060 let usr = format!("{}", root.join("usr").display());
1061 assert_eq!(
1062 resolve_executable("make", &root, &env(&usr)),
1063 Some(root.join("usr/make"))
1064 );
1065 assert_eq!(
1067 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1068 Some(root.join("bin/make"))
1069 );
1070 assert_eq!(
1071 resolve_executable("./bin/make", &root, &env("")),
1072 Some(root.join("bin/make"))
1073 );
1074 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
1075 assert!(!resolvable("nope", &root, &env(&usr)));
1076 std::fs::set_permissions(
1078 root.join("bin/make"),
1079 std::fs::Permissions::from_mode(0o644),
1080 )
1081 .unwrap();
1082 assert_eq!(
1083 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1084 Some(root.join("usr/make"))
1085 );
1086 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
1087 }
1088
1089 #[test]
1090 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1091 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1092 let s = Sandboxed {
1093 profile: Profile::new(Path::new("/w"), &c, None),
1094 backend: Backend::Seatbelt,
1095 network_allow: c.network_allow.clone(),
1096 network_allow_prefix: c.network_allow_prefix.clone(),
1097 };
1098 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1099 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1100 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1101 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1102 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1103 }
1104}