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
322fn regex_escape(text: &str) -> String {
324 let mut out = String::with_capacity(text.len());
325 for c in text.chars() {
326 if "\\^$.|?*+()[]{}\"".contains(c) {
327 out.push('\\');
328 }
329 out.push(c);
330 }
331 out
332}
333
334#[must_use]
336pub fn seatbelt(p: &Profile) -> String {
337 let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
338 out.push_str("(allow file-write*");
339 for w in p.writable.iter().chain([&p.tmp]) {
340 out.push_str(&format!(" (subpath {})", quote(w)));
341 }
342 if let Some(user_tmp) = p.tmp.parent() {
346 out.push_str(&format!(
347 " (regex #\"^{}/tmp\\.[A-Za-z0-9]+(/|$)\")",
348 regex_escape(&user_tmp.to_string_lossy())
349 ));
350 }
351 out.push_str(
352 " (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
353 );
354 if !p.read_only.is_empty() {
355 out.push_str("(deny file-write*");
356 for r in &p.read_only {
357 out.push_str(&format!(" (subpath {})", quote(r)));
358 }
359 out.push_str(")\n");
360 }
361 if !p.hidden.is_empty() {
362 out.push_str("(deny file-read* file-write*");
363 for h in &p.hidden {
364 out.push_str(&format!(" (subpath {})", quote(h)));
365 }
366 out.push_str(")\n");
367 }
368 out.push_str("(deny appleevent-send)\n");
370 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");
371 match p.network {
372 Network::On => {}
373 Network::Off | Network::Loopback => {
374 out.push_str("(deny network*)\n");
375 if p.network == Network::Loopback {
376 out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
379 }
380 out.push_str("(allow network-outbound network-bind network-inbound");
382 for w in p.writable.iter().chain([&p.tmp]) {
383 out.push_str(&format!(
384 " (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
385 q = quote(w)
386 ));
387 }
388 out.push_str(")\n");
389 }
390 }
391 out
392}
393
394pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
396 match backend {
397 Backend::Seatbelt => {
398 let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
399 out.extend_from_slice(argv);
400 Ok(out)
401 }
402 Backend::Landlock(helper) => {
403 let mut out = vec![
404 helper.to_string_lossy().into_owned(),
405 HELPER_ARG.to_owned(),
406 serde_json::to_string(p).map_err(|e| e.to_string())?,
407 "--".to_owned(),
408 ];
409 out.extend_from_slice(argv);
410 Ok(out)
411 }
412 }
413}
414
415#[derive(Debug, Clone)]
417pub struct Sandboxed {
418 pub profile: Profile,
420 pub backend: Backend,
422 pub network_allow: Vec<Vec<String>>,
424 pub network_allow_prefix: Vec<Vec<String>>,
426}
427
428impl Sandboxed {
429 #[must_use]
432 pub fn network_for(&self, argv: &[String]) -> Network {
433 if self.network_allow.iter().any(|p| p.as_slice() == argv)
434 || self
435 .network_allow_prefix
436 .iter()
437 .any(|p| crate::tools::policy::prefix_matches(p, argv))
438 {
439 Network::On
440 } else {
441 self.profile.network
442 }
443 }
444
445 #[must_use]
447 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
448 let profile = self.profile.clone().with_network(network);
449 let failed = |e: String| Outcome {
450 exit_code: None,
451 timed_out: false,
452 spawn_error: Some(e),
453 started: std::time::SystemTime::now(),
454 duration: std::time::Duration::ZERO,
455 stdout: crate::verify::process::Captured::default(),
456 stderr: crate::verify::process::Captured::default(),
457 };
458 if let Some(program) = inv.argv.first()
461 && !resolvable(program, &inv.cwd, &inv.env)
462 {
463 return failed(format!(
464 "could not start `{program}`: No such file or directory"
465 ));
466 }
467 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
468 return failed(format!("the sandbox's temporary directory: {e}"));
469 }
470 let argv = match wrap(&profile, &self.backend, &inv.argv) {
471 Ok(a) => a,
472 Err(e) => return failed(e),
473 };
474 let tmp = profile.tmp.to_string_lossy().into_owned();
475 let mut env: Vec<(String, String)> = inv
476 .env
477 .iter()
478 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
479 .cloned()
480 .collect();
481 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
482 LocalRunner.run(&Invocation {
483 argv,
484 env,
485 ..inv.clone()
486 })
487 }
488}
489
490#[must_use]
497pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
498 let candidate = |p: PathBuf| {
499 executable_file(&p)
500 .then(|| std::fs::canonicalize(&p).ok())
501 .flatten()
502 };
503 if program.is_empty() {
504 return None;
505 }
506 if program.contains('/') {
507 return candidate(cwd.join(program));
508 }
509 let path = env
510 .iter()
511 .find(|(k, _)| k == "PATH")
512 .map(|(_, v)| v.as_str());
513 path.unwrap_or("/usr/bin:/bin")
514 .split(':')
515 .filter(|d| !d.is_empty())
516 .find_map(|d| candidate(cwd.join(d).join(program)))
517}
518
519fn executable_file(p: &Path) -> bool {
521 #[cfg(unix)]
522 {
523 use std::os::unix::fs::PermissionsExt as _;
524 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
525 }
526 #[cfg(not(unix))]
527 {
528 p.is_file()
529 }
530}
531
532fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
534 resolve_executable(program, cwd, env).is_some()
535}
536
537impl CommandRunner for Sandboxed {
538 fn run(&self, inv: &Invocation) -> Outcome {
539 self.run_with_network(inv, self.network_for(&inv.argv))
540 }
541}
542
543pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
547 if config.mode == Mode::Off {
548 return Ok((
549 Runner::Plain,
550 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
551 ));
552 }
553 match backend() {
554 Ok(backend) => {
555 let home = std::env::var_os("HOME").map(PathBuf::from);
556 Ok((
557 Runner::Sandboxed(Sandboxed {
558 profile: Profile::new(root, config, home.as_deref()),
559 backend,
560 network_allow: config.network_allow.clone(),
561 network_allow_prefix: config.network_allow_prefix.clone(),
562 }),
563 None,
564 ))
565 }
566 Err(e) if config.mode == Mode::Required => Err(format!(
567 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
568 )),
569 Err(e) => Ok((
570 Runner::Plain,
571 Some(format!("commands run unsandboxed: {e}")),
572 )),
573 }
574}
575
576pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
578 let text = crate::verify::find_config(root)
579 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
580 .unwrap_or_default();
581 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
582 runner(root, &config)
583}
584
585impl Runner {
586 #[must_use]
590 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
591 match self {
592 Self::Sandboxed(mut s) => {
593 if let Backend::Landlock(helper) = &s.backend
596 && !mounts_supported(helper)
597 {
598 return (
599 Self::Sandboxed(s),
600 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()),
601 );
602 }
603 s.profile = s.profile.carve(read_only, hidden);
604 (Self::Sandboxed(s), None)
605 }
606 Self::Plain => (Self::Plain, None),
607 }
608 }
609}
610
611#[derive(Debug, Clone)]
613pub enum Runner {
614 Sandboxed(Sandboxed),
616 Plain,
618}
619
620impl Runner {
621 #[must_use]
623 pub fn is_sandboxed(&self) -> bool {
624 matches!(self, Self::Sandboxed(_))
625 }
626
627 #[must_use]
629 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
630 match self {
631 Self::Plain => LocalRunner.run(inv),
632 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
633 Self::Sandboxed(s) => s.run(inv),
634 }
635 }
636
637 #[must_use]
640 pub fn scratch(&self) -> Option<PathBuf> {
641 match self {
642 Self::Plain => None,
643 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
644 .ok()
645 .map(|()| s.profile.tmp.clone()),
646 }
647 }
648
649 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
651 match self {
652 Self::Plain => Ok((argv.to_vec(), None)),
653 Self::Sandboxed(s) => {
654 let profile = s.profile.clone().with_network(s.network_for(argv));
655 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
656 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
657 }
658 }
659 }
660}
661
662impl CommandRunner for Runner {
663 fn run(&self, inv: &Invocation) -> Outcome {
664 self.run_with(inv, false)
665 }
666}
667
668pub fn helper_main(args: &[String]) -> String {
671 if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
672 return match probe_mounts() {
673 Ok(()) => std::process::exit(0),
674 Err(e) => e,
675 };
676 }
677 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
678 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
679 };
680 let profile: Profile = match serde_json::from_str(profile) {
681 Ok(p) => p,
682 Err(e) => return format!("the sandbox profile: {e}"),
683 };
684 let argv = &args[2..];
685 if argv.is_empty() {
686 return "nothing to run".to_owned();
687 }
688 enter(&profile, argv)
689}
690
691#[cfg(target_os = "linux")]
692fn enter(profile: &Profile, argv: &[String]) -> String {
693 linux::enter(profile, argv)
694}
695
696const PROBE_MOUNTS: &str = "--probe-mounts";
698
699fn mounts_supported(helper: &Path) -> bool {
702 static CACHED: OnceLock<bool> = OnceLock::new();
703 *CACHED.get_or_init(|| {
704 std::process::Command::new(helper)
705 .args([HELPER_ARG, PROBE_MOUNTS])
706 .stdout(std::process::Stdio::null())
707 .stderr(std::process::Stdio::null())
708 .status()
709 .is_ok_and(|s| s.success())
710 })
711}
712
713#[cfg(target_os = "linux")]
714fn probe_mounts() -> Result<(), String> {
715 linux::probe_mounts()
716}
717
718#[cfg(not(target_os = "linux"))]
719fn probe_mounts() -> Result<(), String> {
720 Err("mount carve-outs are for Linux".to_owned())
721}
722
723#[cfg(not(target_os = "linux"))]
724fn enter(_profile: &Profile, _argv: &[String]) -> String {
725 format!("the {HELPER_ARG} helper is for Linux")
726}
727
728#[cfg(target_os = "linux")]
729mod linux {
730 use std::collections::BTreeMap;
733 use std::path::{Path, PathBuf};
734
735 use landlock::{
736 ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
737 RulesetCreatedAttr, RulesetStatus,
738 };
739 use seccompiler::{
740 SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
741 SeccompRule, TargetArch,
742 };
743
744 use super::{Network, Profile};
745
746 const ABI_LEVEL: ABI = ABI::V3;
747
748 pub(super) fn probe() -> Result<(), String> {
749 Ruleset::default()
750 .set_compatibility(CompatLevel::HardRequirement)
751 .handle_access(AccessFs::from_all(ABI_LEVEL))
752 .and_then(|r| r.create())
753 .map(|_| ())
754 .map_err(|e| format!("Landlock is not available: {e}"))
755 }
756
757 fn mount_carve(profile: &Profile) -> Result<(), String> {
763 use nix::mount::{MsFlags, mount};
764 let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
765 let hidden: Vec<&PathBuf> = profile
766 .hidden
767 .iter()
768 .filter(|h| inside(h) && h.exists())
769 .collect();
770 let sockets: Vec<PathBuf> = daemon_sockets()
771 .into_iter()
772 .filter(|p| !inside(p))
773 .collect();
774 let carved = !profile.read_only.is_empty() || !hidden.is_empty();
775 if !carved && sockets.is_empty() {
776 return Ok(());
777 }
778 if let Err(e) = private_namespace() {
782 return if carved { Err(e) } else { Ok(()) };
783 }
784 let none: Option<&str> = None;
785 for p in profile.read_only.iter().filter(|p| p.exists()) {
786 mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
787 .map_err(|e| format!("bind {}: {e}", p.display()))?;
788 mount(
789 none,
790 p,
791 none,
792 MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
793 none,
794 )
795 .map_err(|e| format!("read-only {}: {e}", p.display()))?;
796 }
797 for h in hidden {
798 if h.is_dir() {
799 mount(
800 Some("tmpfs"),
801 h,
802 Some("tmpfs"),
803 MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
804 Some("size=4k"),
805 )
806 } else {
807 mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
808 }
809 .map_err(|e| format!("hide {}: {e}", h.display()))?;
810 }
811 for p in &sockets {
815 mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
816 .map_err(|e| format!("cover {}: {e}", p.display()))?;
817 }
818 Ok(())
819 }
820
821 fn daemon_sockets() -> Vec<PathBuf> {
824 use std::os::unix::fs::FileTypeExt as _;
825 fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
826 let Ok(entries) = std::fs::read_dir(dir) else {
827 return;
828 };
829 for e in entries.flatten() {
830 let Ok(t) = e.file_type() else { continue };
831 if t.is_socket() {
832 out.push(e.path());
833 } else if t.is_dir() && depth > 0 {
834 walk(&e.path(), depth - 1, out);
835 }
836 }
837 }
838 let mut roots = vec![
839 PathBuf::from("/run"),
840 PathBuf::from("/var/run"),
841 PathBuf::from("/tmp"),
842 ];
843 if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
844 roots.push(PathBuf::from(x));
845 }
846 let mut seen = std::collections::BTreeSet::new();
847 let mut out = Vec::new();
848 for root in roots {
849 let Ok(real) = root.canonicalize() else {
851 continue;
852 };
853 if !seen.insert(real.clone()) {
854 continue;
855 }
856 walk(&real, 3, &mut out);
857 }
858 out.sort();
859 out.dedup();
860 out
861 }
862
863 fn private_namespace() -> Result<(), String> {
866 use nix::mount::{MsFlags, mount};
867 use nix::sched::{CloneFlags, unshare};
868 let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
869 unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
870 .map_err(|e| format!("user namespace: {e}"))?;
871 let write = |file: &str, text: String| {
872 std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
873 };
874 write("/proc/self/setgroups", "deny".to_owned())?;
875 write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
876 write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
877 let none: Option<&str> = None;
878 mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
879 .map_err(|e| format!("private mounts: {e}"))
880 }
881
882 fn locked(p: &Path) -> nix::mount::MsFlags {
884 use nix::mount::MsFlags;
885 use nix::sys::statvfs::{FsFlags, statvfs};
886 let Ok(st) = statvfs(p) else {
887 return MsFlags::empty();
888 };
889 let f = st.flags();
890 let mut out = MsFlags::empty();
891 for (fs, ms) in [
892 (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
893 (FsFlags::ST_NODEV, MsFlags::MS_NODEV),
894 (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
895 (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
896 (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
897 (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
898 ] {
899 if f.contains(fs) {
900 out |= ms;
901 }
902 }
903 out
904 }
905
906 pub(super) fn probe_mounts() -> Result<(), String> {
909 let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
910 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
911 let profile = Profile {
912 writable: Vec::new(),
913 hidden: Vec::new(),
914 read_only: vec![dir.clone()],
915 network: Network::On,
916 tmp: dir.clone(),
917 };
918 let carved = mount_carve(&profile);
919 let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
920 let _ = std::fs::remove_dir_all(&dir);
921 carved.and_then(|()| {
922 refused
923 .then_some(())
924 .ok_or_else(|| "the read-only bind did not hold".to_owned())
925 })
926 }
927
928 fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
931 if hidden.iter().any(|h| h == dir) {
932 return;
933 }
934 if !hidden.iter().any(|h| h.starts_with(dir)) {
935 out.push(dir.to_path_buf());
936 return;
937 }
938 if let Ok(entries) = std::fs::read_dir(dir) {
939 for e in entries.flatten() {
940 readable(&e.path(), hidden, out);
941 }
942 }
943 }
944
945 pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
946 match mount_carve(profile).and_then(|()| confine(profile)) {
947 Ok(()) => {
948 use std::os::unix::process::CommandExt as _;
949 let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
950 format!("could not start `{}`: {e}", argv[0])
951 }
952 Err(e) => format!("the sandbox could not be applied: {e}"),
953 }
954 }
955
956 fn confine(profile: &Profile) -> Result<(), String> {
957 let read = AccessFs::from_read(ABI_LEVEL);
958 let all = AccessFs::from_all(ABI_LEVEL);
959 let mut readable_paths = Vec::new();
960 readable(Path::new("/"), &profile.hidden, &mut readable_paths);
961 let mut ruleset = Ruleset::default()
962 .set_compatibility(CompatLevel::HardRequirement)
963 .handle_access(all)
964 .and_then(|r| r.create())
965 .map_err(|e| e.to_string())?;
966 for p in &readable_paths {
967 if let Ok(fd) = PathFd::new(p) {
968 let access = if p.is_dir() {
969 read
970 } else {
971 read & AccessFs::from_file(ABI_LEVEL)
972 };
973 ruleset = ruleset
974 .add_rule(PathBeneath::new(fd, access))
975 .map_err(|e| e.to_string())?;
976 }
977 }
978 for w in profile.writable.iter().chain([&profile.tmp]) {
979 if let Ok(fd) = PathFd::new(w) {
980 let access = if w.is_dir() {
981 all
982 } else {
983 AccessFs::from_file(ABI_LEVEL)
984 };
985 ruleset = ruleset
986 .add_rule(PathBeneath::new(fd, access))
987 .map_err(|e| e.to_string())?;
988 }
989 }
990 for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
991 if let Ok(fd) = PathFd::new(dev) {
992 ruleset = ruleset
993 .add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
994 .map_err(|e| e.to_string())?;
995 }
996 }
997 let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
998 if status.ruleset != RulesetStatus::FullyEnforced {
999 return Err(format!("Landlock only {:?}", status.ruleset));
1000 }
1001 if profile.network != Network::On {
1002 no_inet()?;
1003 }
1004 Ok(())
1005 }
1006
1007 fn no_inet() -> Result<(), String> {
1009 let rule = |domain: u64| {
1010 SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
1011 .and_then(|c| SeccompRule::new(vec![c]))
1012 .map_err(|e| e.to_string())
1013 };
1014 let rules = vec![
1015 rule(u64::from(libc::AF_INET.unsigned_abs()))?,
1016 rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
1017 ];
1018 let arch: TargetArch = std::env::consts::ARCH
1019 .try_into()
1020 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1021 let filter = SeccompFilter::new(
1022 BTreeMap::from([(libc::SYS_socket, rules)]),
1023 SeccompAction::Allow,
1024 SeccompAction::Errno(libc::EACCES.unsigned_abs()),
1025 arch,
1026 )
1027 .map_err(|e| e.to_string())?;
1028 let program: seccompiler::BpfProgram = filter
1029 .try_into()
1030 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1031 seccompiler::apply_filter(&program).map_err(|e| e.to_string())
1032 }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038
1039 #[test]
1040 fn config_parses_and_defaults_to_loopback() {
1041 assert_eq!(parse("").unwrap_or_default(), Config::default());
1042 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
1043 .unwrap_or_default();
1044 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
1045 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
1046 }
1047
1048 #[test]
1049 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
1050 let home = Path::new("/home/u");
1051 let p = Profile::new(
1052 Path::new("/s/proj/worktrees/001"),
1053 &Config::default(),
1054 Some(home),
1055 );
1056 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
1057 assert!(
1058 !p.writable
1059 .iter()
1060 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
1061 );
1062 assert!(p.hidden.contains(&home.join(".ssh")));
1063 let sb = seatbelt(&p);
1064 assert!(sb.contains("(deny network*)"));
1065 assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
1066 assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
1067 }
1068
1069 #[test]
1070 fn the_executable_is_the_first_on_the_path_canonicalised() {
1071 use std::os::unix::fs::PermissionsExt;
1072 let dir = tempfile::tempdir().unwrap();
1073 let root = std::fs::canonicalize(dir.path()).unwrap();
1074 for d in ["bin", "usr"] {
1075 std::fs::create_dir_all(root.join(d)).unwrap();
1076 let f = root.join(d).join("make");
1077 std::fs::write(&f, "#!/bin/sh\n").unwrap();
1078 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
1079 }
1080 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
1081 let usr = format!("{}", root.join("usr").display());
1082 assert_eq!(
1083 resolve_executable("make", &root, &env(&usr)),
1084 Some(root.join("usr/make"))
1085 );
1086 assert_eq!(
1088 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1089 Some(root.join("bin/make"))
1090 );
1091 assert_eq!(
1092 resolve_executable("./bin/make", &root, &env("")),
1093 Some(root.join("bin/make"))
1094 );
1095 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
1096 assert!(!resolvable("nope", &root, &env(&usr)));
1097 std::fs::set_permissions(
1099 root.join("bin/make"),
1100 std::fs::Permissions::from_mode(0o644),
1101 )
1102 .unwrap();
1103 assert_eq!(
1104 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1105 Some(root.join("usr/make"))
1106 );
1107 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
1108 }
1109
1110 #[test]
1111 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1112 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1113 let s = Sandboxed {
1114 profile: Profile::new(Path::new("/w"), &c, None),
1115 backend: Backend::Seatbelt,
1116 network_allow: c.network_allow.clone(),
1117 network_allow_prefix: c.network_allow_prefix.clone(),
1118 };
1119 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1120 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1121 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1122 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1123 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1124 }
1125}